personal memory agent
0
fork

Configure Feed

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

Add agents app (from concurrent lode)

+4579
+2
apps/agent/__init__.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc
+172
apps/agent/call.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """CLI commands for the agent identity system. 5 + 6 + Auto-discovered by ``think.call`` and mounted as ``sol call agent ...``. 7 + """ 8 + 9 + import json 10 + import os 11 + import subprocess 12 + from datetime import datetime, timezone 13 + from pathlib import Path 14 + 15 + import typer 16 + 17 + app = typer.Typer(help="Agent identity — name and status.") 18 + 19 + 20 + def _get_agent_config() -> dict: 21 + """Read agent config from journal config.""" 22 + from think.utils import get_config 23 + 24 + return get_config().get( 25 + "agent", 26 + { 27 + "name": "sol", 28 + "name_status": "default", 29 + "named_date": None, 30 + "proposal_count": 0, 31 + }, 32 + ) 33 + 34 + 35 + def _update_agent_config(updates: dict) -> dict: 36 + """Update agent config in journal.json and return the full agent block.""" 37 + from think.utils import get_config, get_journal 38 + 39 + config = get_config() 40 + agent = config.get( 41 + "agent", 42 + { 43 + "name": "sol", 44 + "name_status": "default", 45 + "named_date": None, 46 + "proposal_count": 0, 47 + }, 48 + ) 49 + agent.update(updates) 50 + config["agent"] = agent 51 + 52 + config_path = Path(get_journal()) / "config" / "journal.json" 53 + config_path.parent.mkdir(parents=True, exist_ok=True) 54 + with open(config_path, "w", encoding="utf-8") as f: 55 + json.dump(config, f, indent=2) 56 + f.write("\n") 57 + os.chmod(config_path, 0o600) 58 + 59 + return agent 60 + 61 + 62 + @app.command("name") 63 + def name() -> None: 64 + """Show the current agent name and status.""" 65 + agent = _get_agent_config() 66 + typer.echo(json.dumps(agent, indent=2)) 67 + 68 + 69 + @app.command("set-name") 70 + def set_name( 71 + name: str = typer.Argument(..., help="New agent name."), 72 + status: str = typer.Option( 73 + "chosen", 74 + "--status", 75 + "-s", 76 + help="Name status (chosen, self-named, deferred, default).", 77 + ), 78 + ) -> None: 79 + """Set the agent name.""" 80 + agent = _update_agent_config( 81 + { 82 + "name": name, 83 + "name_status": status, 84 + "named_date": datetime.now(timezone.utc).strftime("%Y-%m-%d"), 85 + } 86 + ) 87 + typer.echo(json.dumps(agent, indent=2)) 88 + # Update sol/self.md with new name 89 + from think.awareness import update_self_md_opening, update_self_md_section 90 + 91 + named_date = agent.get("named_date", "") 92 + update_self_md_opening( 93 + f"I am {name}. this is a new journal — we're just getting started." 94 + ) 95 + if named_date: 96 + update_self_md_section("my name", f"{name} (named {named_date})") 97 + else: 98 + update_self_md_section("my name", name) 99 + project_root = Path(__file__).resolve().parent.parent.parent 100 + subprocess.run( 101 + ["make", "skills"], cwd=project_root, check=False, capture_output=True 102 + ) 103 + 104 + 105 + @app.command("reset") 106 + def reset() -> None: 107 + """Reset the agent name to default.""" 108 + agent = _update_agent_config( 109 + { 110 + "name": "sol", 111 + "name_status": "default", 112 + "named_date": None, 113 + } 114 + ) 115 + typer.echo(json.dumps(agent, indent=2)) 116 + project_root = Path(__file__).resolve().parent.parent.parent 117 + subprocess.run( 118 + ["make", "skills"], cwd=project_root, check=False, capture_output=True 119 + ) 120 + 121 + 122 + @app.command("thickness") 123 + def thickness() -> None: 124 + """Show journal thickness signals for naming readiness.""" 125 + from think.awareness import compute_thickness 126 + 127 + typer.echo(json.dumps(compute_thickness(), indent=2)) 128 + 129 + 130 + @app.command("set-owner") 131 + def set_owner( 132 + name: str = typer.Argument(..., help="Owner name."), 133 + bio: str = typer.Option(None, "--bio", "-b", help="Short owner bio."), 134 + ) -> None: 135 + """Set the journal owner's name (and optional bio).""" 136 + from think.awareness import update_self_md_section 137 + from think.utils import get_config, get_journal 138 + 139 + config = get_config() 140 + identity = config.get("identity", {}) 141 + identity["name"] = name 142 + if bio is not None: 143 + identity["bio"] = bio 144 + config["identity"] = identity 145 + 146 + config_path = Path(get_journal()) / "config" / "journal.json" 147 + config_path.parent.mkdir(parents=True, exist_ok=True) 148 + with open(config_path, "w", encoding="utf-8") as f: 149 + json.dump(config, f, indent=2) 150 + f.write("\n") 151 + os.chmod(config_path, 0o600) 152 + 153 + # Update sol/self.md 154 + owner_content = name 155 + if bio: 156 + owner_content += f"\n{bio}" 157 + update_self_md_section("who I'm here for", owner_content) 158 + 159 + typer.echo(json.dumps({"name": name, "bio": bio or ""}, indent=2)) 160 + project_root = Path(__file__).resolve().parent.parent.parent 161 + subprocess.run( 162 + ["make", "skills"], cwd=project_root, check=False, capture_output=True 163 + ) 164 + 165 + 166 + @app.command("sol-init") 167 + def sol_init() -> None: 168 + """Initialize the sol directory with self.md and agency.md.""" 169 + from think.awareness import ensure_sol_directory 170 + 171 + sol_dir = ensure_sol_directory() 172 + typer.echo(json.dumps({"sol_dir": str(sol_dir), "status": "ok"}, indent=2))
+5
apps/agents/app.json
··· 1 + { 2 + "icon": "🤖", 3 + "label": "Agents", 4 + "date_nav": true 5 + }
+206
apps/agents/maint/000_migrate_agent_layout.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Migrate agent output files to the new agents/ directory layout. 5 + 6 + Changes applied: 7 + - Segment agent outputs: YYYYMMDD/HHMMSS_LEN/*.{md,json} -> YYYYMMDD/HHMMSS_LEN/agents/** 8 + - Daily faceted outputs: YYYYMMDD/agents/{topic}_{facet}.{ext} -> YYYYMMDD/agents/{facet}/{topic}.{ext} 9 + 10 + Use --dry-run to preview without writing changes. 11 + """ 12 + 13 + from __future__ import annotations 14 + 15 + import argparse 16 + import shutil 17 + from pathlib import Path 18 + 19 + from think.utils import day_dirs, get_journal, iter_segments, setup_cli 20 + 21 + KNOWN_SEGMENT_AGENT_JSON = frozenset( 22 + {"facets.json", "speakers.json", "activity_state.json"} 23 + ) 24 + 25 + 26 + class MigrationSummary: 27 + """Mutable counters for migration operations.""" 28 + 29 + def __init__(self) -> None: 30 + self.moved = 0 31 + self.cleaned = 0 32 + self.skipped = 0 33 + self.errors = 0 34 + 35 + 36 + def _move_file( 37 + src: Path, dst: Path, *, dry_run: bool, summary: MigrationSummary 38 + ) -> None: 39 + """Move one file, cleaning up identical duplicates when dest already exists.""" 40 + if dst.exists(): 41 + # Dest already exists — clean up src if content is identical. 42 + if src.read_bytes() == dst.read_bytes(): 43 + if dry_run: 44 + print(f"[DRY-RUN] clean {src} (identical to {dst})") 45 + else: 46 + src.unlink() 47 + summary.cleaned += 1 48 + else: 49 + summary.skipped += 1 50 + return 51 + 52 + if dry_run: 53 + print(f"[DRY-RUN] move {src} -> {dst}") 54 + summary.moved += 1 55 + return 56 + 57 + try: 58 + dst.parent.mkdir(parents=True, exist_ok=True) 59 + shutil.move(str(src), str(dst)) 60 + summary.moved += 1 61 + except Exception as exc: 62 + summary.errors += 1 63 + print(f"[ERROR] move failed: {src} -> {dst}: {exc}") 64 + 65 + 66 + def _migrate_segment_outputs( 67 + segment_dir: Path, 68 + *, 69 + facet_names: set[str], 70 + dry_run: bool, 71 + summary: MigrationSummary, 72 + ) -> None: 73 + """Move segment-level agent outputs into segment/agents/ layout.""" 74 + agents_dir = segment_dir / "agents" 75 + 76 + # Move segment markdown outputs from segment root to segment/agents/ 77 + for md_file in sorted(segment_dir.glob("*.md")): 78 + _move_file(md_file, agents_dir / md_file.name, dry_run=dry_run, summary=summary) 79 + 80 + # Move known segment JSON outputs from segment root to segment/agents/ 81 + for json_file in sorted(segment_dir.glob("*.json")): 82 + name = json_file.name 83 + 84 + if name in KNOWN_SEGMENT_AGENT_JSON: 85 + _move_file( 86 + json_file, 87 + agents_dir / name, 88 + dry_run=dry_run, 89 + summary=summary, 90 + ) 91 + continue 92 + 93 + if name.startswith("activity_state_") and name.endswith(".json"): 94 + facet = name[len("activity_state_") : -len(".json")] 95 + if facet in facet_names: 96 + _move_file( 97 + json_file, 98 + agents_dir / facet / "activity_state.json", 99 + dry_run=dry_run, 100 + summary=summary, 101 + ) 102 + continue 103 + 104 + summary.skipped += 1 105 + 106 + 107 + def _migrate_daily_faceted_outputs( 108 + day_dir: Path, 109 + *, 110 + facet_names: set[str], 111 + dry_run: bool, 112 + summary: MigrationSummary, 113 + ) -> None: 114 + """Move daily faceted files from suffix naming to facet subdirectory naming.""" 115 + agents_dir = day_dir / "agents" 116 + if not agents_dir.is_dir(): 117 + return 118 + 119 + # Match longest facet names first to avoid partial matches. 120 + ordered_facets = sorted(facet_names, key=len, reverse=True) 121 + 122 + for file_path in sorted(agents_dir.iterdir()): 123 + if not file_path.is_file() or file_path.suffix not in (".md", ".json"): 124 + continue 125 + 126 + stem = file_path.stem 127 + matched_facet = None 128 + matched_topic = None 129 + 130 + for facet in ordered_facets: 131 + suffix = f"_{facet}" 132 + if stem.endswith(suffix): 133 + topic = stem[: -len(suffix)] 134 + if topic: 135 + matched_facet = facet 136 + matched_topic = topic 137 + break 138 + 139 + if matched_facet is None or matched_topic is None: 140 + summary.skipped += 1 141 + continue 142 + 143 + dest = agents_dir / matched_facet / f"{matched_topic}{file_path.suffix}" 144 + _move_file(file_path, dest, dry_run=dry_run, summary=summary) 145 + 146 + 147 + def migrate_agent_layout(*, dry_run: bool) -> MigrationSummary: 148 + """Run filesystem migration for all day directories in the active journal.""" 149 + summary = MigrationSummary() 150 + journal_path = Path(get_journal()) 151 + 152 + facets_dir = journal_path / "facets" 153 + facet_names = ( 154 + {entry.name for entry in facets_dir.iterdir() if entry.is_dir()} 155 + if facets_dir.is_dir() 156 + else set() 157 + ) 158 + 159 + for day_name, day_abs in sorted(day_dirs().items()): 160 + day_dir = Path(day_abs) 161 + if not day_dir.is_dir(): 162 + continue 163 + 164 + # Segment directories (across all streams) 165 + for _stream, _seg_key, seg_path in iter_segments(day_name): 166 + _migrate_segment_outputs( 167 + seg_path, 168 + facet_names=facet_names, 169 + dry_run=dry_run, 170 + summary=summary, 171 + ) 172 + 173 + # Daily agents/ directory 174 + _migrate_daily_faceted_outputs( 175 + day_dir, 176 + facet_names=facet_names, 177 + dry_run=dry_run, 178 + summary=summary, 179 + ) 180 + 181 + print("Migration complete") 182 + print(f" moved: {summary.moved}") 183 + print(f" cleaned: {summary.cleaned}") 184 + print(f" skipped: {summary.skipped}") 185 + print(f" errors: {summary.errors}") 186 + 187 + return summary 188 + 189 + 190 + def main() -> None: 191 + parser = argparse.ArgumentParser(description="Migrate agent output layout.") 192 + parser.add_argument( 193 + "--dry-run", 194 + action="store_true", 195 + help="Preview moves without writing files.", 196 + ) 197 + args = setup_cli(parser) 198 + 199 + if args.dry_run: 200 + print("[DRY-RUN] No files will be modified.") 201 + 202 + migrate_agent_layout(dry_run=args.dry_run) 203 + 204 + 205 + if __name__ == "__main__": 206 + main()
+255
apps/agents/maint/001_migrate_agent_run_logs.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Migrate agent run logs from flat to per-agent subdirectory layout. 5 + 6 + Changes applied: 7 + - agents/<id>.jsonl -> agents/<name>/<id>.jsonl 8 + - agents/<name>.jsonl symlinks -> agents/<name>.log symlinks 9 + - Build day index files (agents/<day>.jsonl) from migrated data 10 + 11 + Use --dry-run to preview without writing changes. 12 + """ 13 + 14 + from __future__ import annotations 15 + 16 + import argparse 17 + import json 18 + from datetime import datetime 19 + from pathlib import Path 20 + 21 + from think.utils import get_journal, setup_cli 22 + 23 + 24 + class MigrationSummary: 25 + """Mutable counters for migration operations.""" 26 + 27 + def __init__(self) -> None: 28 + self.moved = 0 29 + self.symlinks_removed = 0 30 + self.symlinks_created = 0 31 + self.day_index_entries = 0 32 + self.skipped = 0 33 + self.errors = 0 34 + 35 + 36 + def _read_first_line(path: Path) -> dict | None: 37 + """Read and parse the first JSON line from a file.""" 38 + try: 39 + with open(path, "r") as f: 40 + line = f.readline().strip() 41 + if line: 42 + return json.loads(line) 43 + except (json.JSONDecodeError, IOError): 44 + pass 45 + return None 46 + 47 + 48 + def _read_last_event(path: Path, event_types: set[str]) -> dict | None: 49 + """Read last few lines to find a specific event type.""" 50 + try: 51 + with open(path, "r") as f: 52 + lines = f.readlines() 53 + for line in reversed(lines[-10:]): 54 + line = line.strip() 55 + if not line: 56 + continue 57 + try: 58 + event = json.loads(line) 59 + if event.get("event") in event_types: 60 + return event 61 + except json.JSONDecodeError: 62 + continue 63 + except IOError: 64 + pass 65 + return None 66 + 67 + 68 + def _agent_id_to_day(agent_id: str) -> str: 69 + """Convert agent ID (ms timestamp) to YYYYMMDD string.""" 70 + try: 71 + ts = int(agent_id) / 1000 72 + return datetime.fromtimestamp(ts).strftime("%Y%m%d") 73 + except (ValueError, OSError): 74 + return "" 75 + 76 + 77 + def migrate(agents_dir: Path, dry_run: bool = False) -> MigrationSummary: 78 + """Migrate flat agent files to per-agent subdirectories.""" 79 + summary = MigrationSummary() 80 + 81 + if not agents_dir.exists(): 82 + return summary 83 + 84 + # Phase 1: Remove old symlinks (*.jsonl that are symlinks) 85 + for path in sorted(agents_dir.iterdir()): 86 + if path.is_symlink() and path.suffix == ".jsonl": 87 + print(f" Remove symlink: {path.name}") 88 + if not dry_run: 89 + path.unlink() 90 + summary.symlinks_removed += 1 91 + 92 + # Phase 2: Move flat agent files into subdirectories 93 + # Collect all non-symlink .jsonl files at the root level 94 + # (exclude day index files which are 8-digit dates) 95 + latest_per_name: dict[str, tuple[int, str]] = {} # name -> (agent_id_num, agent_id) 96 + day_entries: dict[str, list[dict]] = {} # day -> [summary_dicts] 97 + 98 + for path in sorted(agents_dir.iterdir()): 99 + if path.is_symlink(): 100 + continue 101 + if not path.is_file(): 102 + continue 103 + if path.suffix != ".jsonl": 104 + continue 105 + # Skip day index files (8-digit filenames) 106 + if len(path.stem) == 8 and path.stem.isdigit(): 107 + continue 108 + 109 + # Determine agent_id and whether active 110 + stem = path.stem 111 + is_active = stem.endswith("_active") 112 + agent_id = stem.replace("_active", "") 113 + 114 + # Read first line to get agent name 115 + first_line = _read_first_line(path) 116 + if not first_line: 117 + print(f" Skip (unreadable): {path.name}") 118 + summary.skipped += 1 119 + continue 120 + 121 + name = first_line.get("name", "unified") 122 + safe_name = name.replace(":", "--") 123 + 124 + # Move to subdirectory 125 + subdir = agents_dir / safe_name 126 + new_path = subdir / path.name 127 + 128 + if new_path.exists(): 129 + print(f" Skip (already exists): {safe_name}/{path.name}") 130 + summary.skipped += 1 131 + continue 132 + 133 + print(f" Move: {path.name} -> {safe_name}/{path.name}") 134 + if not dry_run: 135 + subdir.mkdir(parents=True, exist_ok=True) 136 + path.rename(new_path) 137 + summary.moved += 1 138 + 139 + # Track latest completed agent per name for symlinks 140 + if not is_active: 141 + try: 142 + agent_id_num = int(agent_id) 143 + except ValueError: 144 + summary.skipped += 1 145 + continue 146 + 147 + if name not in latest_per_name or agent_id_num > latest_per_name[name][0]: 148 + latest_per_name[name] = (agent_id_num, agent_id) 149 + 150 + # Build day index entry 151 + start_ts = first_line.get("ts", 0) 152 + day = first_line.get("day") or _agent_id_to_day(agent_id) 153 + if day: 154 + end_event = _read_last_event( 155 + new_path if not dry_run else path, 156 + {"finish", "error"}, 157 + ) 158 + runtime_seconds = None 159 + status = "completed" 160 + if end_event: 161 + if end_event.get("event") == "error": 162 + status = "error" 163 + end_ts = end_event.get("ts", 0) 164 + if end_ts and start_ts: 165 + runtime_seconds = round((end_ts - start_ts) / 1000.0, 1) 166 + 167 + entry = { 168 + "agent_id": agent_id, 169 + "name": name, 170 + "day": day, 171 + "facet": first_line.get("facet"), 172 + "ts": start_ts, 173 + "status": status, 174 + "runtime_seconds": runtime_seconds, 175 + "provider": first_line.get("provider"), 176 + "model": first_line.get("model"), 177 + } 178 + day_entries.setdefault(day, []).append(entry) 179 + summary.day_index_entries += 1 180 + 181 + # Phase 3: Create new .log symlinks 182 + for name, (_agent_id_num, agent_id) in latest_per_name.items(): 183 + safe_name = name.replace(":", "--") 184 + link_path = agents_dir / f"{safe_name}.log" 185 + target = f"{safe_name}/{agent_id}.jsonl" 186 + print(f" Symlink: {safe_name}.log -> {target}") 187 + if not dry_run: 188 + from think.runner import _atomic_symlink 189 + 190 + _atomic_symlink(link_path, target) 191 + summary.symlinks_created += 1 192 + 193 + # Phase 4: Write day index files 194 + for day, entries in sorted(day_entries.items()): 195 + day_index_path = agents_dir / f"{day}.jsonl" 196 + 197 + # Append to existing day index (idempotent: skip entries already present) 198 + existing_ids: set[str] = set() 199 + if day_index_path.exists() and not dry_run: 200 + try: 201 + with open(day_index_path, "r") as f: 202 + for line in f: 203 + line = line.strip() 204 + if line: 205 + try: 206 + existing = json.loads(line) 207 + existing_id = existing.get("agent_id") 208 + if isinstance(existing_id, str): 209 + existing_ids.add(existing_id) 210 + except json.JSONDecodeError: 211 + continue 212 + except IOError: 213 + pass 214 + 215 + new_entries = [e for e in entries if e["agent_id"] not in existing_ids] 216 + if new_entries: 217 + print(f" Day index: {day}.jsonl ({len(new_entries)} entries)") 218 + if not dry_run: 219 + with open(day_index_path, "a") as f: 220 + for entry in new_entries: 221 + f.write(json.dumps(entry) + "\n") 222 + 223 + return summary 224 + 225 + 226 + def main() -> None: 227 + """CLI entrypoint.""" 228 + parser = argparse.ArgumentParser( 229 + description="Migrate agent run logs to per-agent subdirectories" 230 + ) 231 + parser.add_argument( 232 + "--dry-run", action="store_true", help="Preview changes without writing" 233 + ) 234 + args = setup_cli(parser) 235 + 236 + journal_path = Path(get_journal()) 237 + agents_dir = journal_path / "agents" 238 + 239 + if args.dry_run: 240 + print("[DRY-RUN] No files will be modified.") 241 + 242 + print(f"Migrating agent run logs in: {agents_dir}") 243 + summary = migrate(agents_dir, dry_run=args.dry_run) 244 + 245 + print("Migration complete") 246 + print(f" moved: {summary.moved}") 247 + print(f" symlinks_removed: {summary.symlinks_removed}") 248 + print(f" symlinks_created: {summary.symlinks_created}") 249 + print(f" day_index_entries:{summary.day_index_entries}") 250 + print(f" skipped: {summary.skipped}") 251 + print(f" errors: {summary.errors}") 252 + 253 + 254 + if __name__ == "__main__": 255 + main()
+2
apps/agents/maint/__init__.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc
+642
apps/agents/routes.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Agents app - browse historical agent runs by day and facet.""" 5 + 6 + from __future__ import annotations 7 + 8 + import json 9 + import re 10 + from datetime import date, datetime 11 + from functools import lru_cache 12 + from pathlib import Path 13 + from typing import Any 14 + 15 + from flask import Blueprint, jsonify, redirect, render_template, request, url_for 16 + 17 + from convey import state 18 + from convey.utils import DATE_RE, format_date 19 + from think.facets import get_facets 20 + from think.models import calc_agent_cost 21 + from think.talent import get_output_path, get_talent_configs 22 + from think.utils import updated_days 23 + 24 + agents_bp = Blueprint( 25 + "app:agents", 26 + __name__, 27 + url_prefix="/app/agents", 28 + ) 29 + 30 + 31 + def _resolve_output_path( 32 + request_event: dict[str, Any], journal_root: str 33 + ) -> Path | None: 34 + """Resolve output file path from an agent request event. 35 + 36 + Uses explicit output_path if present, otherwise derives from 37 + request fields via get_output_path. 38 + 39 + Returns absolute Path or None if not resolvable. 40 + """ 41 + # Prefer explicit output_path (set for activity agents, custom paths) 42 + if request_event.get("output_path"): 43 + return Path(request_event["output_path"]) 44 + 45 + # Derive from request fields 46 + req_day = request_event.get("day") 47 + if not req_day: 48 + return None 49 + day_dir = Path(journal_root) / req_day 50 + req_segment = request_event.get("segment") 51 + req_facet = request_event.get("facet") 52 + req_name = request_event.get("name", "unified") 53 + req_env = request_event.get("env") or {} 54 + req_stream = req_env.get("SOL_STREAM") if req_env else None 55 + return get_output_path( 56 + day_dir, 57 + req_name, 58 + segment=req_segment, 59 + output_format=request_event.get("output"), 60 + facet=req_facet, 61 + stream=req_stream, 62 + ) 63 + 64 + 65 + def _get_facet_filter() -> str | None: 66 + """Get facet filter from query param or cookie. 67 + 68 + Returns None for all-facet mode. 69 + """ 70 + facet = request.args.get("facet") 71 + if facet is None: 72 + facet = request.cookies.get("selectedFacet") or None 73 + return facet 74 + 75 + 76 + def _agent_id_to_day(agent_id: str) -> str: 77 + """Convert agent_id (millisecond timestamp) to YYYYMMDD day string.""" 78 + try: 79 + ts = int(agent_id) / 1000 80 + return datetime.fromtimestamp(ts).strftime("%Y%m%d") 81 + except (ValueError, OSError): 82 + return "" 83 + 84 + 85 + def _parse_agent_events( 86 + lines: list[str], *, collect_events: bool = False 87 + ) -> dict[str, Any]: 88 + """Parse agent event lines and extract counts and cost data. 89 + 90 + Args: 91 + lines: List of JSONL lines 92 + collect_events: If True, include parsed event dicts as "events" key 93 + 94 + Returns: 95 + Dict with: thinking_count, tool_count, model, provider, usage, finish_ts, 96 + error_ts, error_message, and optionally events 97 + """ 98 + result: dict[str, Any] = { 99 + "thinking_count": 0, 100 + "tool_count": 0, 101 + "model": None, 102 + "provider": None, 103 + "usage": None, 104 + "finish_ts": None, 105 + "error_ts": None, 106 + "error_message": None, 107 + } 108 + events: list[dict] = [] if collect_events else None 109 + 110 + for line in lines: 111 + line = line.strip() 112 + if not line: 113 + continue 114 + try: 115 + event = json.loads(line) 116 + if events is not None: 117 + # Strip bulky provider-native data not used by the frontend 118 + event.pop("raw", None) 119 + events.append(event) 120 + event_type = event.get("event") 121 + if event_type == "thinking": 122 + result["thinking_count"] += 1 123 + elif event_type == "tool_start": 124 + result["tool_count"] += 1 125 + elif event_type == "start": 126 + result["model"] = event.get("model") 127 + result["provider"] = event.get("provider") 128 + elif event_type == "finish": 129 + result["finish_ts"] = event.get("ts", 0) 130 + result["usage"] = event.get("usage") 131 + elif event_type == "error": 132 + result["error_ts"] = event.get("ts", 0) 133 + msg = event.get("error", "") 134 + if msg: 135 + result["error_message"] = msg[:200] 136 + except json.JSONDecodeError: 137 + continue 138 + 139 + if events is not None: 140 + result["events"] = events 141 + 142 + return result 143 + 144 + 145 + def _parse_agent_file(agent_file: Path) -> dict[str, Any] | None: 146 + """Parse agent JSONL file and extract metadata. 147 + 148 + Returns dict with: id, name, start, status, prompt, facet, failed, 149 + runtime_seconds, thinking_count, tool_count, cost, model, provider, 150 + error_message. 151 + Returns None if file cannot be parsed. 152 + """ 153 + from think.cortex_client import get_agent_end_state 154 + 155 + try: 156 + with open(agent_file, "r") as f: 157 + lines = f.readlines() 158 + 159 + if not lines: 160 + return None 161 + 162 + first_line = lines[0].strip() 163 + if not first_line: 164 + return None 165 + 166 + request_event = json.loads(first_line) 167 + if request_event.get("event") != "request": 168 + return None 169 + 170 + # Extract agent ID from filename 171 + is_active = "_active.jsonl" in agent_file.name 172 + agent_id = agent_file.stem.replace("_active", "") 173 + 174 + # Parse events using shared helper 175 + event_data = _parse_agent_events(lines[1:]) 176 + 177 + agent_info: dict[str, Any] = { 178 + "id": agent_id, 179 + "name": request_event.get("name", "unified"), 180 + "start": request_event.get("ts", 0), 181 + "status": "running" if is_active else "completed", 182 + "prompt": request_event.get("prompt", ""), 183 + "facet": request_event.get("facet"), 184 + "failed": False, 185 + "runtime_seconds": None, 186 + "thinking_count": event_data["thinking_count"], 187 + "tool_count": event_data["tool_count"], 188 + "cost": None, 189 + "model": event_data["model"], 190 + "provider": request_event.get("provider") or event_data.get("provider"), 191 + "error_message": event_data["error_message"], 192 + } 193 + 194 + # Check for output file (generators only) 195 + output_file = None 196 + req_output = request_event.get("output") 197 + if req_output: 198 + out_path = _resolve_output_path(request_event, state.journal_root) 199 + if out_path and out_path.exists(): 200 + req_day = request_event.get("day") 201 + day_dir = Path(state.journal_root) / req_day if req_day else None 202 + if day_dir and out_path.is_relative_to(day_dir): 203 + output_file = str(out_path.relative_to(day_dir)) 204 + else: 205 + output_file = str(out_path.relative_to(state.journal_root)) 206 + agent_info["output_file"] = output_file 207 + 208 + # For completed agents, determine end state and calculate cost 209 + if not is_active: 210 + end_state = get_agent_end_state(agent_id) 211 + agent_info["failed"] = end_state in ("error", "unknown") 212 + 213 + # Calculate runtime from finish or error timestamp 214 + end_ts = event_data["finish_ts"] or event_data["error_ts"] 215 + if end_ts and agent_info["start"]: 216 + agent_info["runtime_seconds"] = (end_ts - agent_info["start"]) / 1000.0 217 + 218 + # Calculate cost 219 + agent_info["cost"] = calc_agent_cost( 220 + event_data["model"], event_data["usage"] 221 + ) 222 + 223 + return agent_info 224 + except (json.JSONDecodeError, IOError): 225 + return None 226 + 227 + 228 + def _get_agent_day(agent_file: Path) -> str: 229 + """Get the logical day for an agent from its request event. 230 + 231 + Prefers the ``day`` field from the request event (the day being processed) 232 + over the agent_id timestamp (when the agent actually ran). This ensures 233 + overnight dream agents appear under the day they processed. 234 + """ 235 + agent_id = agent_file.stem.replace("_active", "") 236 + try: 237 + with open(agent_file, "r") as f: 238 + first_line = f.readline().strip() 239 + if first_line: 240 + request_event = json.loads(first_line) 241 + req_day = request_event.get("day") 242 + if req_day: 243 + return req_day 244 + except (json.JSONDecodeError, IOError): 245 + pass 246 + return _agent_id_to_day(agent_id) 247 + 248 + 249 + def _get_agents_for_day(day: str, facet_filter: str | None = None) -> list[dict]: 250 + """Get all agent runs for a specific day. 251 + 252 + Uses the day index file for fast lookup instead of scanning all agent files. 253 + 254 + Args: 255 + day: YYYYMMDD day string 256 + facet_filter: Optional facet to filter by (None = all facets) 257 + 258 + Returns: 259 + List of agent info dicts sorted by start time (newest first) 260 + """ 261 + agents_dir = Path(state.journal_root) / "agents" 262 + if not agents_dir.exists(): 263 + return [] 264 + 265 + agents = [] 266 + 267 + # Read day index for completed agents 268 + day_index_path = agents_dir / f"{day}.jsonl" 269 + if day_index_path.exists(): 270 + try: 271 + with open(day_index_path, "r") as f: 272 + for line in f: 273 + line = line.strip() 274 + if not line: 275 + continue 276 + try: 277 + entry = json.loads(line) 278 + except json.JSONDecodeError: 279 + continue 280 + 281 + # Filter by facet if specified 282 + if facet_filter is not None and entry.get("facet") != facet_filter: 283 + continue 284 + 285 + # Locate the actual file for full parsing 286 + agent_id = entry.get("agent_id", "") 287 + name = entry.get("name", "unified") 288 + safe_name = name.replace(":", "--") 289 + agent_file = agents_dir / safe_name / f"{agent_id}.jsonl" 290 + if not agent_file.exists(): 291 + continue 292 + 293 + agent_info = _parse_agent_file(agent_file) 294 + if agent_info: 295 + agents.append(agent_info) 296 + except IOError: 297 + pass 298 + 299 + # Also check for running agents (only have _active files, no day index entry yet) 300 + for agent_file in agents_dir.glob("*/*_active.jsonl"): 301 + if "_pending" in agent_file.name: 302 + continue 303 + if _get_agent_day(agent_file) != day: 304 + continue 305 + 306 + agent_info = _parse_agent_file(agent_file) 307 + if not agent_info: 308 + continue 309 + 310 + if facet_filter is not None and agent_info.get("facet") != facet_filter: 311 + continue 312 + 313 + agents.append(agent_info) 314 + 315 + # Sort by start time (newest first) 316 + agents.sort(key=lambda x: x["start"], reverse=True) 317 + return agents 318 + 319 + 320 + @lru_cache(maxsize=1) 321 + def _build_agents_meta() -> dict[str, dict[str, Any]]: 322 + """Build agent metadata dict from all talent configs. 323 + 324 + Returns dict mapping agent name to metadata with capability fields 325 + for frontend display. Cached for process lifetime since talent configs 326 + are static. 327 + """ 328 + configs = get_talent_configs(include_disabled=True) 329 + agents: dict[str, dict[str, Any]] = {} 330 + 331 + for name, config in configs.items(): 332 + agents[name] = { 333 + "title": config.get("title", name), 334 + "description": config.get("description"), 335 + "color": config.get("color", "#6c757d"), 336 + "source": config.get("source", "system"), 337 + "app": config.get("app"), 338 + "schedule": config.get("schedule"), 339 + "type": config.get("type"), 340 + "output_format": config.get("output"), 341 + "multi_facet": bool(config.get("multi_facet")), 342 + } 343 + 344 + return agents 345 + 346 + 347 + # ============================================================================= 348 + # Page Routes 349 + # ============================================================================= 350 + 351 + 352 + @agents_bp.route("/") 353 + def index() -> Any: 354 + """Redirect to today's agent history.""" 355 + today = date.today().strftime("%Y%m%d") 356 + return redirect(url_for("app:agents.agents_day", day=today)) 357 + 358 + 359 + @agents_bp.route("/<day>") 360 + def agents_day(day: str) -> str: 361 + """Render agent history viewer for a specific day.""" 362 + if not DATE_RE.fullmatch(day): 363 + return "", 404 364 + 365 + title = format_date(day) 366 + 367 + return render_template("app.html", title=title) 368 + 369 + 370 + # ============================================================================= 371 + # API Routes 372 + # ============================================================================= 373 + 374 + 375 + @agents_bp.route("/api/agents/<day>") 376 + def api_agents_day(day: str) -> Any: 377 + """Get agent runs and metadata for a specific day. 378 + 379 + Returns flat data for frontend grouping/rendering. 380 + 381 + Query params: 382 + facet: Optional facet filter (from cookie if not specified) 383 + 384 + Returns: 385 + { 386 + "runs": [run objects...], 387 + "agents": {name: metadata...}, 388 + "facets": {name: {title, color}...} 389 + } 390 + """ 391 + if not DATE_RE.fullmatch(day): 392 + return jsonify({"error": "Invalid day format"}), 400 393 + 394 + facet_filter = _get_facet_filter() 395 + 396 + runs = _get_agents_for_day(day, facet_filter) 397 + agents = _build_agents_meta() 398 + facets = { 399 + name: {"title": f.get("title", name), "color": f.get("color")} 400 + for name, f in get_facets().items() 401 + } 402 + 403 + return jsonify( 404 + { 405 + "runs": runs, 406 + "agents": agents, 407 + "facets": facets, 408 + } 409 + ) 410 + 411 + 412 + @agents_bp.route("/api/run/<agent_id>") 413 + def api_agent_run(agent_id: str) -> Any: 414 + """Return full agent run detail with metadata and parsed events.""" 415 + # Locate the agent JSONL file 416 + journal_path = Path(state.journal_root) 417 + agents_dir = journal_path / "agents" 418 + # Search subdirectories for the agent file 419 + agent_file = None 420 + for match in agents_dir.glob(f"*/{agent_id}.jsonl"): 421 + agent_file = match 422 + break 423 + 424 + if not agent_file: 425 + # Check if the agent is still running 426 + for match in agents_dir.glob(f"*/{agent_id}_active.jsonl"): 427 + return jsonify({"error": "Agent run is still in progress"}), 202 428 + return jsonify({"error": f"Agent run {agent_id} not found"}), 404 429 + 430 + try: 431 + from think.cortex_client import get_agent_end_state 432 + 433 + with open(agent_file, "r", encoding="utf-8") as f: 434 + lines = f.readlines() 435 + 436 + if not lines: 437 + return jsonify({"error": f"Agent run {agent_id} is malformed"}), 500 438 + 439 + first_line = lines[0].strip() 440 + if not first_line: 441 + return jsonify({"error": f"Agent run {agent_id} is malformed"}), 500 442 + 443 + request_event = json.loads(first_line) 444 + if request_event.get("event") != "request": 445 + return jsonify({"error": f"Agent run {agent_id} is malformed"}), 500 446 + 447 + event_data = _parse_agent_events(lines[1:], collect_events=True) 448 + 449 + output_file = None 450 + req_output = request_event.get("output") 451 + if req_output: 452 + out_path = _resolve_output_path(request_event, state.journal_root) 453 + if out_path and out_path.exists(): 454 + req_day = request_event.get("day") 455 + day_dir = Path(state.journal_root) / req_day if req_day else None 456 + if day_dir and out_path.is_relative_to(day_dir): 457 + output_file = str(out_path.relative_to(day_dir)) 458 + else: 459 + output_file = str(out_path.relative_to(state.journal_root)) 460 + 461 + start_ts = request_event.get("ts", 0) 462 + runtime_seconds = None 463 + end_ts = event_data["finish_ts"] or event_data["error_ts"] 464 + if end_ts and start_ts: 465 + runtime_seconds = (end_ts - start_ts) / 1000.0 466 + 467 + end_state = get_agent_end_state(agent_id) 468 + 469 + run: dict[str, Any] = { 470 + "id": agent_id, 471 + "name": request_event.get("name", "unified"), 472 + "start": start_ts, 473 + "status": "completed", 474 + "prompt": request_event.get("prompt", ""), 475 + "facet": request_event.get("facet"), 476 + "failed": end_state in ("error", "unknown"), 477 + "runtime_seconds": runtime_seconds, 478 + "thinking_count": event_data["thinking_count"], 479 + "tool_count": event_data["tool_count"], 480 + "cost": calc_agent_cost(event_data["model"], event_data["usage"]), 481 + "model": event_data["model"], 482 + "provider": request_event.get("provider") or event_data.get("provider"), 483 + "error_message": event_data["error_message"], 484 + "output_file": output_file, 485 + "events": event_data.get("events", []), 486 + } 487 + run["day"] = request_event.get("day") or _agent_id_to_day(agent_id) 488 + return jsonify(run) 489 + except Exception as e: 490 + return jsonify({"error": str(e)}), 500 491 + 492 + 493 + @agents_bp.route("/api/output/<day>/<path:filename>") 494 + def api_output_file(day: str, filename: str) -> Any: 495 + """Serve output file content for the run detail output tab. 496 + 497 + Returns JSON with content, format, and filename. 498 + Path is validated to stay within the journal directory. 499 + 500 + Supports two path styles: 501 + - Day-relative: ``agents/flow.md`` → resolved under ``{day}/`` 502 + - Journal-relative: ``facets/work/activities/...`` → resolved under journal root 503 + """ 504 + if not DATE_RE.fullmatch(day): 505 + return jsonify(error="Invalid day format"), 400 506 + 507 + journal_root = Path(state.journal_root).resolve() 508 + 509 + # Journal-relative paths (e.g., activity output under facets/) 510 + if filename.startswith("facets/"): 511 + file_path = (journal_root / filename).resolve() 512 + else: 513 + file_path = (journal_root / day / filename).resolve() 514 + 515 + # Security: ensure path is within the journal directory 516 + try: 517 + file_path.relative_to(journal_root) 518 + except ValueError: 519 + return jsonify(error="Invalid path"), 403 520 + 521 + if not file_path.is_file(): 522 + return jsonify(error="File not found"), 404 523 + 524 + ext = file_path.suffix.lower() 525 + fmt = "json" if ext == ".json" else "md" 526 + 527 + try: 528 + content = file_path.read_text(encoding="utf-8") 529 + except IOError: 530 + return jsonify(error="Could not read file"), 500 531 + 532 + return jsonify(content=content, format=fmt, filename=file_path.name) 533 + 534 + 535 + @agents_bp.route("/api/preview/<path:name>") 536 + def api_preview_prompt(name: str) -> Any: 537 + """Return the complete rendered prompt for an agent. 538 + 539 + Returns: 540 + { 541 + "name": str, 542 + "title": str, 543 + "full_prompt": str, 544 + "multi_facet": bool 545 + } 546 + """ 547 + try: 548 + from think.talent import get_agent 549 + 550 + config = get_agent(name) 551 + 552 + system_instruction = config.get("system_instruction", "") 553 + extra_context = config.get("extra_context", "") 554 + user_instruction = config.get("user_instruction", "") 555 + # Compose full prompt with labeled sections 556 + labeled = [] 557 + if system_instruction: 558 + labeled.append(f"## System Instruction\n\n{system_instruction}") 559 + if extra_context: 560 + labeled.append(f"## Context\n\n{extra_context}") 561 + if user_instruction: 562 + labeled.append(f"## Instructions\n\n{user_instruction}") 563 + full_prompt = "\n\n".join(labeled) 564 + 565 + return jsonify( 566 + { 567 + "name": name, 568 + "title": config.get("title", name), 569 + "full_prompt": full_prompt, 570 + "multi_facet": config.get("multi_facet", False), 571 + } 572 + ) 573 + except FileNotFoundError: 574 + return jsonify({"error": f"Agent '{name}' not found"}), 404 575 + except Exception as e: 576 + return jsonify({"error": str(e)}), 500 577 + 578 + 579 + @agents_bp.route("/api/stats/<month>") 580 + def api_stats(month: str) -> Any: 581 + """Return agent run counts per day per facet for a month. 582 + 583 + Args: 584 + month: YYYYMM format month string 585 + 586 + Returns: 587 + JSON dict mapping day (YYYYMMDD) to {facet: count, ...} 588 + For unfaceted runs, uses "_none" as the key. 589 + """ 590 + if not re.fullmatch(r"\d{6}", month): 591 + return jsonify({"error": "Invalid month format, expected YYYYMM"}), 400 592 + 593 + agents_dir = Path(state.journal_root) / "agents" 594 + if not agents_dir.exists(): 595 + return jsonify({}) 596 + 597 + stats: dict[str, dict[str, int]] = {} 598 + 599 + # Read day index files for the month 600 + for day_index_file in agents_dir.glob(f"{month}*.jsonl"): 601 + day = day_index_file.stem 602 + if not re.fullmatch(r"\d{8}", day): 603 + continue 604 + 605 + try: 606 + with open(day_index_file, "r") as f: 607 + for line in f: 608 + line = line.strip() 609 + if not line: 610 + continue 611 + try: 612 + entry = json.loads(line) 613 + except json.JSONDecodeError: 614 + continue 615 + 616 + facet = entry.get("facet") or "_none" 617 + if day not in stats: 618 + stats[day] = {} 619 + stats[day][facet] = stats[day].get(facet, 0) + 1 620 + except IOError: 621 + continue 622 + 623 + return jsonify(stats) 624 + 625 + 626 + @agents_bp.route("/api/badge-count") 627 + def api_badge_count() -> Any: 628 + """Get count of failed agent runs for today (for app icon badge).""" 629 + today = date.today().strftime("%Y%m%d") 630 + agents = _get_agents_for_day(today, facet_filter=None) 631 + failed_count = sum(1 for a in agents if a.get("failed")) 632 + return jsonify({"count": failed_count}) 633 + 634 + 635 + @agents_bp.route("/api/updated-days") 636 + def api_updated_days() -> Any: 637 + """Return journal days with pending reprocessing.""" 638 + today = date.today().strftime("%Y%m%d") 639 + try: 640 + return jsonify(updated_days(exclude={today})) 641 + except Exception: 642 + return jsonify([])
+2419
apps/agents/workspace.html
··· 1 + {# Agents day view - historical agent runs browser #} 2 + 3 + <style> 4 + /* ============================================================================ 5 + Layout & Navigation 6 + ============================================================================ */ 7 + .agents-content { 8 + max-width: 1200px; 9 + margin: 0 auto; 10 + padding: 1.5rem 2rem 2rem; 11 + border-top: 1px solid var(--facet-border, #e5e0db); 12 + } 13 + 14 + .view-header { 15 + display: flex; 16 + align-items: center; 17 + gap: 1rem; 18 + margin-bottom: 1.5rem; 19 + padding-bottom: 0.75rem; 20 + border-bottom: 1px solid var(--facet-border, #e5e0db); 21 + } 22 + 23 + .back-btn { 24 + background: none; 25 + border: 1px solid #ddd; 26 + border-radius: 6px; 27 + padding: 0.5rem 0.75rem; 28 + cursor: pointer; 29 + font-size: 1rem; 30 + display: flex; 31 + align-items: center; 32 + gap: 0.5rem; 33 + color: #666; 34 + transition: background 0.2s, border-color 0.2s, color 0.2s; 35 + } 36 + 37 + .back-btn:hover { 38 + background: #f5f5f5; 39 + border-color: #ccc; 40 + color: #333; 41 + } 42 + 43 + .back-btn:active { 44 + background: #e0e0e0; 45 + } 46 + 47 + .back-btn:focus-visible { 48 + outline: 2px solid var(--facet-color, #b06a1a); 49 + outline-offset: 2px; 50 + } 51 + 52 + .view-title { 53 + margin: 0; 54 + font-size: 1.5rem; 55 + font-weight: 600; 56 + color: #333; 57 + } 58 + 59 + /* ============================================================================ 60 + Day Summary Bar 61 + ============================================================================ */ 62 + .day-summary { 63 + display: flex; 64 + gap: 1.5rem; 65 + padding: 0.5rem 0 1rem; 66 + font-size: 0.85rem; 67 + color: #666; 68 + } 69 + 70 + .day-summary .summary-item { 71 + display: flex; 72 + align-items: center; 73 + gap: 0.3rem; 74 + } 75 + 76 + .day-summary .summary-failed { 77 + color: #c62828; 78 + } 79 + 80 + /* ============================================================================ 81 + Agent Card Grid 82 + ============================================================================ */ 83 + .agent-section { 84 + margin-bottom: 2rem; 85 + } 86 + 87 + .section-title { 88 + font-size: 0.85rem; 89 + font-weight: 600; 90 + color: #666; 91 + text-transform: uppercase; 92 + letter-spacing: 0.05em; 93 + margin-bottom: 0.75rem; 94 + padding-bottom: 0.25rem; 95 + border-bottom: 1px solid var(--facet-border, #e5e0db); 96 + } 97 + 98 + .agent-cards { 99 + display: grid; 100 + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); 101 + gap: 1rem; 102 + } 103 + 104 + .agent-card { 105 + background: white; 106 + border: 2px solid var(--facet-border, #e5e0db); 107 + border-left: 3px solid #6c757d; 108 + border-radius: 8px; 109 + padding: 1rem; 110 + cursor: pointer; 111 + transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.1s ease; 112 + } 113 + 114 + .agent-card:hover { 115 + border-top-color: #007bff; 116 + border-right-color: #007bff; 117 + border-bottom-color: #007bff; 118 + box-shadow: 0 2px 8px rgba(0, 123, 255, 0.15); 119 + } 120 + 121 + .agent-card:active { 122 + transform: scale(0.98); 123 + } 124 + 125 + .agent-card-header { 126 + display: flex; 127 + align-items: flex-start; 128 + justify-content: space-between; 129 + margin-bottom: 0.25rem; 130 + } 131 + 132 + .agent-card-title { 133 + font-weight: 600; 134 + color: #333; 135 + font-size: 1rem; 136 + line-height: 1.3; 137 + margin: 0; 138 + } 139 + 140 + .agent-card-badges { 141 + display: flex; 142 + gap: 0.4rem; 143 + flex-shrink: 0; 144 + } 145 + 146 + .badge { 147 + display: inline-flex; 148 + align-items: center; 149 + justify-content: center; 150 + padding: 0.15rem 0.5rem; 151 + border-radius: 12px; 152 + font-size: 0.75rem; 153 + font-weight: 500; 154 + } 155 + 156 + .badge-success { 157 + background: #e8f5e9; 158 + color: #2e7d32; 159 + } 160 + 161 + .badge-failed { 162 + background: #ffebee; 163 + color: #c62828; 164 + } 165 + 166 + .agent-card-description { 167 + font-size: 0.85rem; 168 + color: #6b7280; 169 + margin: 0 0 0.5rem; 170 + display: -webkit-box; 171 + -webkit-line-clamp: 2; 172 + -webkit-box-orient: vertical; 173 + overflow: hidden; 174 + } 175 + 176 + .agent-card-caps { 177 + display: flex; 178 + gap: 0.4rem; 179 + margin-bottom: 0.5rem; 180 + flex-wrap: wrap; 181 + } 182 + 183 + .cap-badge { 184 + display: inline-flex; 185 + align-items: center; 186 + padding: 0.15rem 0.5rem; 187 + border-radius: 3px; 188 + font-size: 0.7rem; 189 + font-weight: 500; 190 + background: #f0f0f0; 191 + color: #555; 192 + text-transform: uppercase; 193 + letter-spacing: 0.04em; 194 + } 195 + 196 + .agent-card-activity { 197 + display: flex; 198 + justify-content: space-between; 199 + align-items: center; 200 + gap: 0.75rem; 201 + font-size: 0.8rem; 202 + color: #666; 203 + } 204 + 205 + .activity-counts { 206 + display: flex; 207 + gap: 0.75rem; 208 + } 209 + 210 + .facet-dots { 211 + display: flex; 212 + gap: 0.4rem; 213 + flex-shrink: 0; 214 + } 215 + 216 + .activity-item { 217 + display: flex; 218 + align-items: center; 219 + gap: 0.25rem; 220 + } 221 + 222 + .facet-dot { 223 + width: 10px; 224 + height: 10px; 225 + border-radius: 50%; 226 + display: inline-flex; 227 + align-items: center; 228 + justify-content: center; 229 + overflow: hidden; 230 + border: 1px solid rgba(0,0,0,0.1); 231 + } 232 + 233 + /* ============================================================================ 234 + Run List View 235 + ============================================================================ */ 236 + .run-list-header { 237 + display: flex; 238 + align-items: center; 239 + gap: 1rem; 240 + margin-bottom: 1rem; 241 + } 242 + 243 + .preview-btn { 244 + background: #f8f9fa; 245 + border: 1px solid #ddd; 246 + border-radius: 6px; 247 + padding: 0.4rem 0.75rem; 248 + cursor: pointer; 249 + font-size: 0.85rem; 250 + color: #666; 251 + transition: background 0.2s, border-color 0.2s; 252 + margin-left: auto; 253 + } 254 + 255 + .preview-btn:hover { 256 + background: #e9ecef; 257 + border-color: #ccc; 258 + } 259 + 260 + .preview-btn:active { 261 + background: #dee2e6; 262 + } 263 + 264 + .preview-btn:focus-visible { 265 + outline: 2px solid var(--facet-color, #b06a1a); 266 + outline-offset: 2px; 267 + } 268 + 269 + .runs-table { 270 + width: 100%; 271 + border-collapse: collapse; 272 + } 273 + 274 + .runs-table th, 275 + .runs-table td { 276 + padding: 0.75rem 0.5rem; 277 + text-align: left; 278 + border-bottom: 1px solid var(--facet-border, #e5e0db); 279 + } 280 + 281 + .runs-table th { 282 + font-weight: 600; 283 + color: #666; 284 + font-size: 0.85rem; 285 + } 286 + 287 + .runs-table tr { 288 + cursor: pointer; 289 + transition: background 0.15s; 290 + } 291 + 292 + .runs-table tbody tr:hover { 293 + background: rgba(0, 0, 0, 0.03); 294 + } 295 + 296 + .runs-table tbody tr:active { 297 + background: rgba(0, 0, 0, 0.06); 298 + } 299 + 300 + .col-status { width: 50px; text-align: center; } 301 + .col-time { width: 100px; } 302 + .col-model { width: 140px; } 303 + .col-provider { width: 90px; } 304 + .col-runtime { width: 80px; text-align: right; } 305 + .col-activity { width: 50px; text-align: center; } 306 + .col-facet { width: 120px; } 307 + .col-output { width: 120px; } 308 + .col-prompt { } 309 + 310 + .status-icon { 311 + font-size: 1.1rem; 312 + } 313 + 314 + .status-ok { color: #4caf50; } 315 + .status-failed { color: #f44336; } 316 + .status-running { color: #ff9800; } 317 + 318 + .facet-tag { 319 + display: inline-flex; 320 + align-items: center; 321 + gap: 0.3rem; 322 + padding: 0.2rem 0.5rem; 323 + border-radius: 4px; 324 + font-size: 0.85rem; 325 + background: #f5f5f5; 326 + } 327 + 328 + .prompt-snippet { 329 + max-width: 400px; 330 + overflow: hidden; 331 + text-overflow: ellipsis; 332 + white-space: nowrap; 333 + color: #666; 334 + font-size: 0.85rem; 335 + } 336 + 337 + .model-name { 338 + font-size: 0.85rem; 339 + color: #5f6368; 340 + } 341 + 342 + .provider-name { 343 + font-size: 0.8rem; 344 + color: #888; 345 + } 346 + 347 + .error-preview { 348 + font-size: 0.8rem; 349 + color: #c62828; 350 + margin-top: 0.2rem; 351 + overflow: hidden; 352 + text-overflow: ellipsis; 353 + white-space: nowrap; 354 + max-width: 400px; 355 + } 356 + 357 + /* ============================================================================ 358 + Run Detail View 359 + ============================================================================ */ 360 + .run-detail-breadcrumb { 361 + display: flex; 362 + align-items: center; 363 + gap: 0.45rem; 364 + margin-bottom: 1rem; 365 + font-size: 0.9rem; 366 + font-weight: 500; 367 + } 368 + 369 + .crumb-link { 370 + color: #1a73e8; 371 + text-decoration: none; 372 + cursor: pointer; 373 + } 374 + 375 + .crumb-link:hover { 376 + text-decoration: underline; 377 + } 378 + 379 + .crumb-link:focus-visible { 380 + outline: 2px solid var(--facet-color, #b06a1a); 381 + outline-offset: 2px; 382 + border-radius: 2px; 383 + } 384 + 385 + .crumb-sep { 386 + color: #999; 387 + } 388 + 389 + .crumb-current { 390 + color: #5f6368; 391 + } 392 + 393 + .run-detail-header-card { 394 + background: #fafafa; 395 + border: 1px solid var(--facet-border, #e5e0db); 396 + border-radius: 8px; 397 + padding: 1rem; 398 + margin-bottom: 1rem; 399 + } 400 + 401 + .run-detail-title-row { 402 + display: flex; 403 + align-items: center; 404 + gap: 0.75rem; 405 + flex-wrap: wrap; 406 + margin-bottom: 0.4rem; 407 + } 408 + 409 + .run-detail-title { 410 + margin: 0; 411 + font-size: 1.2rem; 412 + font-weight: 600; 413 + color: #333; 414 + } 415 + 416 + .run-status-badge { 417 + display: inline-flex; 418 + align-items: center; 419 + padding: 0.2rem 0.6rem; 420 + border-radius: 999px; 421 + font-size: 0.75rem; 422 + font-weight: 600; 423 + } 424 + 425 + .run-status-badge.status-completed { 426 + background: #e8f5e9; 427 + color: #2e7d32; 428 + } 429 + 430 + .run-status-badge.status-failed { 431 + background: #ffebee; 432 + color: #c62828; 433 + } 434 + 435 + .run-status-badge.status-running { 436 + background: #e3f2fd; 437 + color: #1565c0; 438 + } 439 + 440 + .run-detail-subtitle { 441 + color: #6b7280; 442 + font-size: 0.9rem; 443 + margin-bottom: 0.85rem; 444 + } 445 + 446 + .run-detail-prompt { 447 + color: #6b7280; 448 + font-size: 0.85rem; 449 + margin-bottom: 0.85rem; 450 + padding: 0.5rem 0.7rem; 451 + background: #f8f9fa; 452 + border-radius: 6px; 453 + border-left: 3px solid #d1d5db; 454 + } 455 + 456 + .run-meta-grid { 457 + display: grid; 458 + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); 459 + gap: 0.75rem; 460 + } 461 + 462 + .run-meta-item { 463 + display: flex; 464 + flex-direction: column; 465 + gap: 0.2rem; 466 + background: #fff; 467 + border: 1px solid var(--facet-border, #e5e0db); 468 + border-radius: 6px; 469 + padding: 0.6rem 0.7rem; 470 + } 471 + 472 + .meta-label { 473 + font-size: 0.75rem; 474 + color: #6b7280; 475 + } 476 + 477 + .meta-value { 478 + font-size: 0.9rem; 479 + color: #2f3437; 480 + } 481 + 482 + .rd-tabs { 483 + gap: 8px; 484 + padding: 8px 0; 485 + border-bottom: 1px solid var(--facet-border, #e5e0db); 486 + flex-shrink: 0; 487 + display: flex; 488 + } 489 + 490 + .rd-tab { 491 + padding: 6px 14px; 492 + border: 1px solid var(--facet-border, #e5e0db); 493 + border-radius: 6px; 494 + font-size: 13px; 495 + cursor: pointer; 496 + background: #fff; 497 + color: #374151; 498 + transition: background 0.15s, border-color 0.15s, color 0.15s; 499 + } 500 + 501 + .rd-tab:hover { 502 + background: #f9fafb; 503 + } 504 + 505 + .rd-tab.active { 506 + background: var(--facet-color, #b06a1a); 507 + border-color: var(--facet-color, #b06a1a); 508 + color: #fff; 509 + } 510 + 511 + .rd-tab:not(.active):active { 512 + background: #e5e7eb; 513 + } 514 + 515 + .rd-tab:focus-visible { 516 + outline: 2px solid var(--facet-color, #b06a1a); 517 + outline-offset: 2px; 518 + } 519 + 520 + .rd-panel { 521 + padding-top: 0.85rem; 522 + } 523 + 524 + .rd-tab-pane { 525 + display: none; 526 + } 527 + 528 + .rd-tab-pane.active { 529 + display: block; 530 + } 531 + 532 + .run-output-content { 533 + line-height: 1.6; 534 + font-size: 0.9rem; 535 + } 536 + 537 + .run-output-content pre { 538 + background: #f5f5f5; 539 + padding: 1rem; 540 + border-radius: 4px; 541 + overflow-x: auto; 542 + white-space: pre-wrap; 543 + word-wrap: break-word; 544 + font-size: 0.85rem; 545 + line-height: 1.5; 546 + margin: 0; 547 + } 548 + 549 + .run-output-content .rendered-markdown h1, 550 + .run-output-content .rendered-markdown h2, 551 + .run-output-content .rendered-markdown h3 { 552 + margin-top: 1rem; 553 + margin-bottom: 0.5rem; 554 + } 555 + 556 + .run-output-content .rendered-markdown ul, 557 + .run-output-content .rendered-markdown ol { 558 + padding-left: 1.5rem; 559 + } 560 + 561 + .run-output-content .rendered-markdown p { 562 + margin-bottom: 0.5rem; 563 + } 564 + 565 + .run-loading { 566 + text-align: center; 567 + padding: 2rem; 568 + color: #666; 569 + } 570 + 571 + /* Event timeline */ 572 + .event-timeline { 573 + display: flex; 574 + flex-direction: column; 575 + gap: 0.5rem; 576 + } 577 + 578 + .event-block { 579 + border-left: 3px solid #e0e0e0; 580 + padding: 0.5rem 1rem; 581 + font-size: 0.875rem; 582 + line-height: 1.5; 583 + } 584 + 585 + .event-block .event-time { 586 + font-size: 0.75rem; 587 + color: #999; 588 + margin-right: 0.5rem; 589 + font-family: monospace; 590 + } 591 + 592 + /* Thinking events */ 593 + .event-thinking { 594 + border-left-color: #7c4dff; 595 + background: #f5f0ff; 596 + } 597 + 598 + .event-thinking .event-label { 599 + font-weight: 600; 600 + color: #7c4dff; 601 + font-size: 0.75rem; 602 + } 603 + 604 + .event-thinking .thinking-text { 605 + margin-top: 0.3rem; 606 + color: #555; 607 + white-space: pre-wrap; 608 + } 609 + 610 + /* Tool events */ 611 + .event-tool { 612 + border-left-color: #0288d1; 613 + background: #f0f7ff; 614 + } 615 + 616 + .event-tool .event-label { 617 + font-weight: 600; 618 + color: #0288d1; 619 + font-size: 0.75rem; 620 + } 621 + 622 + .event-tool .tool-name { 623 + font-weight: 600; 624 + color: #01579b; 625 + } 626 + 627 + .event-tool .tool-toggle { 628 + background: none; 629 + border: none; 630 + cursor: pointer; 631 + font-size: 0.75rem; 632 + font-weight: 500; 633 + color: #0288d1; 634 + padding: 0.1rem 0.3rem; 635 + border-radius: 3px; 636 + margin-left: 0.5rem; 637 + } 638 + 639 + .event-tool .tool-toggle:hover { 640 + background: #e1f0ff; 641 + } 642 + 643 + .event-tool .tool-toggle:active { 644 + background: #c8e1f7; 645 + } 646 + 647 + .event-tool .tool-toggle:focus-visible { 648 + outline: 2px solid var(--facet-color, #b06a1a); 649 + outline-offset: 1px; 650 + } 651 + 652 + .event-tool .tool-details { 653 + margin-top: 0.4rem; 654 + } 655 + 656 + .event-tool .tool-details pre { 657 + background: #e8f0f8; 658 + padding: 0.5rem; 659 + border-radius: 4px; 660 + overflow-x: auto; 661 + font-size: 0.8rem; 662 + margin: 0.3rem 0; 663 + max-height: 300px; 664 + overflow-y: auto; 665 + } 666 + 667 + .event-tool .tool-section-label { 668 + font-size: 0.75rem; 669 + font-weight: 600; 670 + color: #666; 671 + margin-top: 0.4rem; 672 + } 673 + 674 + .event-tool .tool-incomplete { 675 + font-style: italic; 676 + color: #999; 677 + font-size: 0.82rem; 678 + } 679 + 680 + .event-tool .tool-incomplete-badge { 681 + font-size: 0.72rem; 682 + color: #e65100; 683 + font-style: italic; 684 + margin-left: 0.4rem; 685 + } 686 + 687 + /* Error events */ 688 + .event-error { 689 + border-left-color: #d32f2f; 690 + background: #fff5f5; 691 + } 692 + 693 + .event-error .event-label { 694 + font-weight: 600; 695 + color: #d32f2f; 696 + font-size: 0.75rem; 697 + } 698 + 699 + .event-error .error-message { 700 + font-weight: 500; 701 + color: #c62828; 702 + } 703 + 704 + .event-error .error-trace { 705 + margin-top: 0.3rem; 706 + } 707 + 708 + .event-error .error-trace pre { 709 + background: #fee; 710 + padding: 0.5rem; 711 + border-radius: 4px; 712 + overflow-x: auto; 713 + font-size: 0.8rem; 714 + max-height: 200px; 715 + overflow-y: auto; 716 + } 717 + 718 + /* Info events */ 719 + .event-info { 720 + border-left-color: #bbb; 721 + color: #888; 722 + font-size: 0.82rem; 723 + font-style: italic; 724 + } 725 + 726 + /* Finish event */ 727 + .event-finish { 728 + border-left-color: #388e3c; 729 + background: #f0faf0; 730 + } 731 + 732 + .event-finish .event-label { 733 + font-weight: 600; 734 + color: #388e3c; 735 + font-size: 0.75rem; 736 + } 737 + 738 + .event-finish .finish-result { 739 + margin-top: 0.3rem; 740 + line-height: 1.6; 741 + } 742 + 743 + .event-finish .finish-empty { 744 + color: #9e9e9e; 745 + font-style: italic; 746 + font-size: 0.85rem; 747 + } 748 + 749 + .event-finish .finish-result pre { 750 + background: #e8f5e9; 751 + padding: 0.5rem; 752 + border-radius: 4px; 753 + overflow-x: auto; 754 + font-size: 0.8rem; 755 + } 756 + 757 + .event-finish .finish-result code { 758 + background: #e0f0e0; 759 + padding: 0.1rem 0.3rem; 760 + border-radius: 3px; 761 + font-size: 0.85em; 762 + } 763 + 764 + .event-finish .finish-result pre code { 765 + background: none; 766 + padding: 0; 767 + } 768 + 769 + /* Flow events (agent_updated, continue) */ 770 + .event-flow { 771 + border-left-color: #f9a825; 772 + background: #fffde7; 773 + font-size: 0.82rem; 774 + color: #666; 775 + } 776 + 777 + .event-flow .flow-label { 778 + font-weight: 500; 779 + color: #f57f17; 780 + } 781 + 782 + /* ============================================================================ 783 + Preview Modal 784 + ============================================================================ */ 785 + .modal-backdrop { 786 + display: none; 787 + position: fixed; 788 + top: 0; 789 + left: 0; 790 + right: 0; 791 + bottom: 0; 792 + background: rgba(0,0,0,0.5); 793 + z-index: var(--z-modals, 200); 794 + } 795 + 796 + .modal-backdrop.show { 797 + display: flex; 798 + align-items: center; 799 + justify-content: center; 800 + cursor: pointer; 801 + } 802 + 803 + .modal-content { 804 + background: white; 805 + border-radius: 8px; 806 + width: 90%; 807 + max-width: 800px; 808 + max-height: 80vh; 809 + display: flex; 810 + flex-direction: column; 811 + } 812 + 813 + .modal-header { 814 + display: flex; 815 + align-items: center; 816 + justify-content: space-between; 817 + padding: 1rem; 818 + border-bottom: 1px solid var(--facet-border, #e5e0db); 819 + } 820 + 821 + .modal-title { 822 + margin: 0; 823 + font-size: 1.1rem; 824 + font-weight: 600; 825 + } 826 + 827 + .modal-close { 828 + background: none; 829 + border: none; 830 + font-size: 1.5rem; 831 + cursor: pointer; 832 + color: #666; 833 + padding: 0; 834 + line-height: 1; 835 + } 836 + 837 + .modal-close:hover { 838 + color: #333; 839 + } 840 + 841 + .modal-close:active { 842 + color: #111; 843 + } 844 + 845 + .modal-close:focus-visible { 846 + outline: 2px solid var(--facet-color, #b06a1a); 847 + outline-offset: 2px; 848 + border-radius: 4px; 849 + } 850 + 851 + .modal-body { 852 + padding: 1rem; 853 + overflow-y: auto; 854 + flex: 1; 855 + } 856 + 857 + .modal-body pre { 858 + background: #f5f5f5; 859 + padding: 1rem; 860 + border-radius: 4px; 861 + overflow-x: auto; 862 + white-space: pre-wrap; 863 + word-wrap: break-word; 864 + font-size: 0.85rem; 865 + line-height: 1.5; 866 + margin: 0; 867 + } 868 + 869 + /* ============================================================================ 870 + Empty States 871 + ============================================================================ */ 872 + .empty-state { 873 + text-align: center; 874 + color: #666; 875 + padding: calc(30vh - 2rem) 1rem 2rem; 876 + min-height: calc(100vh - 300px); 877 + box-sizing: border-box; 878 + } 879 + 880 + .empty-state-icon { 881 + font-size: 2.5rem; 882 + margin: 0 auto 0.75rem; 883 + opacity: 0.5; 884 + width: 72px; 885 + height: 72px; 886 + line-height: 72px; 887 + background: rgba(0, 0, 0, 0.025); 888 + border-radius: 50%; 889 + } 890 + 891 + .empty-state-text { 892 + font-size: 1.1rem; 893 + margin-bottom: 0.75rem; 894 + } 895 + 896 + .empty-state-hint { 897 + font-size: 0.85rem; 898 + color: #aaa; 899 + } 900 + 901 + /* ============================================================================ 902 + Loading 903 + ============================================================================ */ 904 + .loading { 905 + text-align: center; 906 + padding: 3rem; 907 + color: #666; 908 + } 909 + 910 + .spinner { 911 + display: inline-block; 912 + width: 32px; 913 + height: 32px; 914 + border: 3px solid #e0e0e0; 915 + border-top-color: #007bff; 916 + border-radius: 50%; 917 + animation: spin 1s linear infinite; 918 + margin-bottom: 1rem; 919 + } 920 + 921 + .updated-banner { 922 + background: #fff8e1; 923 + border: 1px solid #ffe082; 924 + border-radius: 6px; 925 + padding: 0.75rem 1rem; 926 + margin-bottom: 1rem; 927 + font-size: 0.9rem; 928 + color: #5d4037; 929 + } 930 + 931 + .updated-banner-title { 932 + font-weight: 600; 933 + margin-bottom: 0.25rem; 934 + } 935 + 936 + .updated-banner a { 937 + color: #e65100; 938 + text-decoration: none; 939 + font-weight: 500; 940 + } 941 + 942 + .updated-banner a:hover { 943 + text-decoration: underline; 944 + } 945 + 946 + @keyframes spin { 947 + to { transform: rotate(360deg); } 948 + } 949 + 950 + @media (prefers-reduced-motion: reduce) { 951 + .spinner { 952 + animation: none; 953 + border-top-color: #007bff; 954 + opacity: 0.7; 955 + } 956 + } 957 + 958 + .agent-card:focus-visible { 959 + outline: 2px solid var(--facet-color, #b06a1a); 960 + outline-offset: 2px; 961 + } 962 + 963 + .runs-table tbody tr:focus-visible { 964 + outline: 2px solid var(--facet-color, #b06a1a); 965 + outline-offset: -2px; 966 + } 967 + 968 + /* Responsive: tablet */ 969 + @media (max-width: 768px) { 970 + .col-model, .col-provider, .col-output, .col-prompt { 971 + display: none; 972 + } 973 + .runs-table { 974 + font-size: 0.85rem; 975 + } 976 + #list-view { 977 + overflow-x: auto; 978 + } 979 + } 980 + 981 + /* Responsive: mobile */ 982 + @media (max-width: 480px) { 983 + .col-runtime, .col-facet { 984 + display: none; 985 + } 986 + .runs-table { 987 + font-size: 0.8rem; 988 + } 989 + } 990 + 991 + .sr-only { 992 + position: absolute; 993 + width: 1px; 994 + height: 1px; 995 + padding: 0; 996 + margin: -1px; 997 + overflow: hidden; 998 + clip: rect(0, 0, 0, 0); 999 + white-space: nowrap; 1000 + border: 0; 1001 + } 1002 + </style> 1003 + 1004 + <div class="agents-content"> 1005 + <h2 class="sr-only">Agent Runs</h2> 1006 + <!-- Loading state --> 1007 + <div id="loading-view" class="loading" role="status" aria-live="polite"> 1008 + <div class="spinner" aria-hidden="true"></div> 1009 + <div>Loading agents...</div> 1010 + </div> 1011 + 1012 + <div id="agents-status" class="sr-only" role="status" aria-live="polite"></div> 1013 + 1014 + <!-- Card Grid View --> 1015 + <div id="grid-view" style="display: none;"> 1016 + <div id="updated-banner" class="updated-banner" style="display: none;"></div> 1017 + <div id="day-summary" class="day-summary" style="display: none;"></div> 1018 + <div id="agent-groups"></div> 1019 + <div id="empty-state" class="empty-state" style="display: none;"> 1020 + <div class="empty-state-icon">🤖</div> 1021 + <div class="empty-state-text">No agent runs on this day</div> 1022 + <div class="empty-state-hint">Agent runs will appear here when they complete</div> 1023 + </div> 1024 + </div> 1025 + 1026 + <!-- Run List View --> 1027 + <div id="list-view" style="display: none;"> 1028 + <div class="view-header"> 1029 + <button class="back-btn" onclick="showGridView()"> 1030 + <span>←</span> Back 1031 + </button> 1032 + <h2 class="view-title" id="list-view-title"></h2> 1033 + <button class="preview-btn" id="preview-btn" onclick="showPreview()"> 1034 + View Prompt 1035 + </button> 1036 + </div> 1037 + <div class="run-list-header"> 1038 + <span id="list-view-count"></span> 1039 + </div> 1040 + <table class="runs-table"> 1041 + <thead> 1042 + <tr> 1043 + <th class="col-status"></th> 1044 + <th class="col-time">time</th> 1045 + <th class="col-model">model</th> 1046 + <th class="col-provider">provider</th> 1047 + <th class="col-runtime">runtime</th> 1048 + <th class="col-activity" title="Thinking events">💭</th> 1049 + <th class="col-activity" title="Tool calls">🔧</th> 1050 + <th class="col-activity" title="Cost">💰</th> 1051 + <th class="col-facet">facet</th> 1052 + <th class="col-output">output</th> 1053 + <th class="col-prompt">prompt</th> 1054 + </tr> 1055 + </thead> 1056 + <tbody id="runs-tbody"></tbody> 1057 + </table> 1058 + </div> 1059 + 1060 + <!-- Run Detail View --> 1061 + <div id="run-detail-view" style="display: none;"> 1062 + <div class="run-detail-breadcrumb"> 1063 + <a class="crumb-link" id="crumb-agents" href="#" onclick="event.preventDefault(); showGridView()">Agents</a> 1064 + <span class="crumb-sep">/</span> 1065 + <a class="crumb-link" id="crumb-agent" href="#">Agent Name</a> 1066 + <span class="crumb-sep">/</span> 1067 + <span class="crumb-current" id="crumb-run">Run 09:41</span> 1068 + </div> 1069 + <div id="run-detail-header-mount"></div> 1070 + <div id="run-detail-tabs" class="rd-tabs" role="tablist" aria-label="run detail tabs"> 1071 + <button type="button" class="rd-tab active" data-tab="log" id="tab-log" role="tab" aria-selected="true" aria-controls="run-detail-log-pane" tabindex="0">Run Log</button> 1072 + <button type="button" class="rd-tab" data-tab="output" id="tab-output" role="tab" aria-selected="false" aria-controls="run-detail-output-pane" tabindex="-1" style="display: none;">Output</button> 1073 + </div> 1074 + <div id="run-detail-panel" class="rd-panel"> 1075 + <div id="run-detail-log-pane" class="rd-tab-pane active" role="tabpanel" aria-labelledby="tab-log"></div> 1076 + <div id="run-detail-output-pane" class="rd-tab-pane" role="tabpanel" aria-labelledby="tab-output"></div> 1077 + </div> 1078 + </div> 1079 + </div> 1080 + 1081 + <script src="{{ vendor_lib('marked') }}"></script> 1082 + 1083 + <!-- Preview Modal --> 1084 + <div id="preview-modal" class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="preview-modal-title"> 1085 + <div class="modal-content"> 1086 + <div class="modal-header"> 1087 + <h3 class="modal-title" id="preview-modal-title">Agent Prompt</h3> 1088 + <button class="modal-close" onclick="hidePreview()">&times;</button> 1089 + </div> 1090 + <div class="modal-body"> 1091 + <pre id="preview-modal-content"></pre> 1092 + </div> 1093 + </div> 1094 + </div> 1095 + 1096 + <script> 1097 + (function() { 1098 + // State 1099 + let currentDay = null; 1100 + let currentName = null; 1101 + let currentRunId = null; 1102 + let cachedRunDetail = null; 1103 + let runOutputLoaded = false; 1104 + let runOutputLoading = false; 1105 + let allRuns = []; 1106 + let agentsMeta = {}; 1107 + let facetsMeta = {}; 1108 + let previousPreviewFocus = null; 1109 + let previewFocusTrapHandler = null; 1110 + 1111 + // Get current day from URL path 1112 + function getDayFromUrl() { 1113 + const path = window.location.pathname; 1114 + const match = path.match(/\/app\/agents\/(\d{8})$/); 1115 + return match ? match[1] : null; 1116 + } 1117 + 1118 + // Format timestamp to time string 1119 + function formatTime(ts) { 1120 + if (!ts) return '\u2014'; 1121 + const d = new Date(ts); 1122 + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); 1123 + } 1124 + 1125 + // Format runtime 1126 + function formatRuntime(seconds) { 1127 + if (seconds === null || seconds === undefined) return '\u2014'; 1128 + if (seconds < 60) return `${Math.round(seconds)}s`; 1129 + const mins = Math.floor(seconds / 60); 1130 + const secs = Math.round(seconds % 60); 1131 + return `${mins}m ${secs}s`; 1132 + } 1133 + 1134 + // Truncate text for display 1135 + function truncate(text, maxLen = 80) { 1136 + if (!text) return '\u2014'; 1137 + const clean = text.replace(/\s+/g, ' ').trim(); 1138 + if (clean.length <= maxLen) return clean; 1139 + return clean.substring(0, maxLen) + '...'; 1140 + } 1141 + 1142 + // Format cost in USD with exact value in title 1143 + function formatCost(costUSD) { 1144 + if (costUSD === null || costUSD === undefined) { 1145 + return { text: '\u2014', title: '' }; 1146 + } 1147 + const exactUSD = '$' + costUSD.toFixed(4); 1148 + if (costUSD > 0 && costUSD < 0.01) { 1149 + return { text: '<$0.01', title: exactUSD }; 1150 + } 1151 + return { text: '$' + costUSD.toFixed(2), title: exactUSD }; 1152 + } 1153 + 1154 + // Shorten model name for display 1155 + function formatModel(model) { 1156 + if (!model) return '\u2014'; 1157 + // Strip common provider prefixes and date suffixes 1158 + return model 1159 + .replace(/^(models\/|accounts\/[^/]+\/models\/)/, '') 1160 + .replace(/-\d{8}$/, ''); 1161 + } 1162 + 1163 + function formatAgentDisplayName(name) { 1164 + if (!name) return '\u2014'; 1165 + const meta = agentsMeta[name]; 1166 + if (meta && meta.title) return meta.title; 1167 + return name.charAt(0).toUpperCase() + name.slice(1); 1168 + } 1169 + 1170 + // ========================================================================= 1171 + // Grouping & Aggregation (moved from server) 1172 + // ========================================================================= 1173 + 1174 + function groupRunsByAgent(runs) { 1175 + const groups = {}; 1176 + 1177 + for (const run of runs) { 1178 + const name = run.name; 1179 + if (!groups[name]) { 1180 + const meta = agentsMeta[name] || {}; 1181 + groups[name] = { 1182 + name: name, 1183 + title: meta.title || name, 1184 + description: meta.description || null, 1185 + color: meta.color || '#6c757d', 1186 + source: meta.source || 'system', 1187 + app: meta.app || null, 1188 + schedule: meta.schedule || null, 1189 + type: meta.type || null, 1190 + multi_facet: meta.multi_facet || false, 1191 + run_count: 0, 1192 + failed_count: 0, 1193 + thinking_count: 0, 1194 + tool_count: 0, 1195 + total_cost: 0.0, 1196 + facets: new Set(), 1197 + }; 1198 + } 1199 + 1200 + const g = groups[name]; 1201 + g.run_count++; 1202 + if (run.failed) g.failed_count++; 1203 + g.thinking_count += (run.thinking_count || 0); 1204 + g.tool_count += (run.tool_count || 0); 1205 + if (run.cost != null) g.total_cost += run.cost; 1206 + if (run.facet) g.facets.add(run.facet); 1207 + } 1208 + 1209 + return groups; 1210 + } 1211 + 1212 + function organizeGroups(groups) { 1213 + const systemGroups = []; 1214 + const appGroups = {}; 1215 + 1216 + for (const group of Object.values(groups)) { 1217 + if (group.source === 'system') { 1218 + systemGroups.push(group); 1219 + } else { 1220 + const appName = group.app || 'unknown'; 1221 + if (!appGroups[appName]) appGroups[appName] = []; 1222 + appGroups[appName].push(group); 1223 + } 1224 + } 1225 + 1226 + // Sort alphabetically by title 1227 + const sortByTitle = (a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase()); 1228 + systemGroups.sort(sortByTitle); 1229 + for (const appName of Object.keys(appGroups)) { 1230 + appGroups[appName].sort(sortByTitle); 1231 + } 1232 + 1233 + return { system: systemGroups, apps: appGroups }; 1234 + } 1235 + 1236 + // ========================================================================= 1237 + // Data Loading 1238 + // ========================================================================= 1239 + 1240 + function hideAllViews() { 1241 + document.getElementById('loading-view').style.display = 'none'; 1242 + document.getElementById('grid-view').style.display = 'none'; 1243 + document.getElementById('list-view').style.display = 'none'; 1244 + document.getElementById('run-detail-view').style.display = 'none'; 1245 + } 1246 + 1247 + function loadUpdatedBanner() { 1248 + const banner = document.getElementById('updated-banner'); 1249 + const now = new Date(); 1250 + const today = String(now.getFullYear()) + String(now.getMonth() + 1).padStart(2, '0') + String(now.getDate()).padStart(2, '0'); 1251 + if (currentDay !== today || window.selectedFacet) { 1252 + banner.style.display = 'none'; 1253 + return; 1254 + } 1255 + fetch('api/updated-days') 1256 + .then(r => r.json()) 1257 + .then(days => { 1258 + if (!days.length) { 1259 + banner.style.display = 'none'; 1260 + return; 1261 + } 1262 + const links = days.map(d => { 1263 + const label = d.slice(0, 4) + '-' + d.slice(4, 6) + '-' + d.slice(6); 1264 + return `<a href="${d}">${label}</a>`; 1265 + }).join(', '); 1266 + banner.innerHTML = `<div class="updated-banner-title">Days with pending reprocessing</div>${links}`; 1267 + banner.style.display = 'block'; 1268 + }) 1269 + .catch(() => { banner.style.display = 'none'; }); 1270 + } 1271 + 1272 + async function loadAgents() { 1273 + currentDay = getDayFromUrl(); 1274 + if (!currentDay) return; 1275 + 1276 + hideAllViews(); 1277 + document.getElementById('loading-view').style.display = 'block'; 1278 + document.getElementById('agents-status').textContent = ''; 1279 + 1280 + try { 1281 + const response = await fetch(`api/agents/${currentDay}`); 1282 + const data = await response.json(); 1283 + 1284 + allRuns = data.runs || []; 1285 + agentsMeta = data.agents || {}; 1286 + facetsMeta = data.facets || {}; 1287 + 1288 + renderGridView(); 1289 + document.getElementById('agents-status').textContent = 1290 + allRuns.length + ' agent run' + (allRuns.length !== 1 ? 's' : '') + ' loaded'; 1291 + 1292 + // Restore hash-based view after data load 1293 + if (location.hash) { 1294 + handleHashChange(); 1295 + } 1296 + } catch (error) { 1297 + console.error('Error loading agents:', error); 1298 + document.getElementById('loading-view').innerHTML = 1299 + '<div class="empty-state" aria-live="polite">' + 1300 + '<div class="empty-state-icon">⚠️</div>' + 1301 + '<div class="empty-state-text">Unable to load agents</div>' + 1302 + '<div class="empty-state-hint">The server may be temporarily unavailable. Check your connection and try again.</div>' + 1303 + '<button class="back-btn" onclick="loadAgents()" style="margin-top: 1rem;">Try again</button>' + 1304 + '</div>'; 1305 + } 1306 + } 1307 + 1308 + // ========================================================================= 1309 + // Grid View 1310 + // ========================================================================= 1311 + 1312 + function renderGridView() { 1313 + hideAllViews(); 1314 + document.getElementById('grid-view').style.display = 'block'; 1315 + 1316 + const container = document.getElementById('agent-groups'); 1317 + container.innerHTML = ''; 1318 + 1319 + if (allRuns.length === 0) { 1320 + document.getElementById('day-summary').style.display = 'none'; 1321 + document.getElementById('empty-state').style.display = 'block'; 1322 + return; 1323 + } 1324 + 1325 + document.getElementById('empty-state').style.display = 'none'; 1326 + 1327 + // Group and organize 1328 + const groups = groupRunsByAgent(allRuns); 1329 + const organized = organizeGroups(groups); 1330 + 1331 + // Render summary bar 1332 + renderDaySummary(allRuns); 1333 + 1334 + // Render system agents 1335 + if (organized.system.length > 0) { 1336 + container.appendChild(renderSection('system agents', organized.system)); 1337 + } 1338 + 1339 + // Render app agent sections 1340 + const appNames = Object.keys(organized.apps).sort(); 1341 + for (const appName of appNames) { 1342 + const title = appName; 1343 + container.appendChild(renderSection(title, organized.apps[appName])); 1344 + } 1345 + } 1346 + 1347 + function renderDaySummary(runs) { 1348 + const summary = document.getElementById('day-summary'); 1349 + const totalRuns = runs.length; 1350 + const failedRuns = runs.filter(r => r.failed).length; 1351 + let totalCost = 0; 1352 + for (const r of runs) { 1353 + if (r.cost != null) totalCost += r.cost; 1354 + } 1355 + 1356 + const parts = []; 1357 + parts.push(`<span class="summary-item">${totalRuns} run${totalRuns !== 1 ? 's' : ''}</span>`); 1358 + if (failedRuns > 0) { 1359 + parts.push(`<span class="summary-item summary-failed">${failedRuns} failed</span>`); 1360 + } 1361 + if (totalCost > 0) { 1362 + const costInfo = formatCost(totalCost); 1363 + parts.push(`<span class="summary-item" title="${costInfo.title}">💰 ${costInfo.text}</span>`); 1364 + } 1365 + 1366 + summary.innerHTML = parts.join(''); 1367 + summary.style.display = totalRuns > 0 ? 'flex' : 'none'; 1368 + } 1369 + 1370 + // Render a section with cards 1371 + function renderSection(title, agents) { 1372 + const section = document.createElement('div'); 1373 + section.className = 'agent-section'; 1374 + 1375 + const titleEl = document.createElement('h3'); 1376 + titleEl.className = 'section-title'; 1377 + titleEl.textContent = title; 1378 + section.appendChild(titleEl); 1379 + 1380 + const grid = document.createElement('div'); 1381 + grid.className = 'agent-cards'; 1382 + 1383 + for (const agent of agents) { 1384 + grid.appendChild(renderCard(agent)); 1385 + } 1386 + 1387 + section.appendChild(grid); 1388 + return section; 1389 + } 1390 + 1391 + // Render an agent card 1392 + function renderCard(agent) { 1393 + const card = document.createElement('div'); 1394 + card.className = 'agent-card'; 1395 + card.style.borderLeftColor = agent.color; 1396 + card.onclick = () => showRunList(agent.name); 1397 + card.setAttribute('role', 'button'); 1398 + card.setAttribute('tabindex', '0'); 1399 + card.setAttribute('aria-label', agent.title); 1400 + card.addEventListener('keydown', e => { 1401 + if (e.key !== 'Enter' && e.key !== ' ') return; 1402 + e.preventDefault(); 1403 + showRunList(agent.name); 1404 + }); 1405 + 1406 + // Header with title and badges 1407 + const header = document.createElement('div'); 1408 + header.className = 'agent-card-header'; 1409 + 1410 + const title = document.createElement('h4'); 1411 + title.className = 'agent-card-title'; 1412 + title.textContent = agent.title; 1413 + header.appendChild(title); 1414 + 1415 + const badges = document.createElement('div'); 1416 + badges.className = 'agent-card-badges'; 1417 + 1418 + const successCount = agent.run_count - agent.failed_count; 1419 + if (successCount > 0) { 1420 + const successBadge = document.createElement('span'); 1421 + successBadge.className = 'badge badge-success'; 1422 + successBadge.textContent = '✓ ' + successCount; 1423 + badges.appendChild(successBadge); 1424 + } 1425 + 1426 + if (agent.failed_count > 0) { 1427 + const failBadge = document.createElement('span'); 1428 + failBadge.className = 'badge badge-failed'; 1429 + failBadge.textContent = '✗ ' + agent.failed_count; 1430 + badges.appendChild(failBadge); 1431 + } 1432 + 1433 + header.appendChild(badges); 1434 + card.appendChild(header); 1435 + 1436 + // Description 1437 + if (agent.description) { 1438 + const desc = document.createElement('p'); 1439 + desc.className = 'agent-card-description'; 1440 + desc.textContent = agent.description; 1441 + desc.title = agent.description; 1442 + card.appendChild(desc); 1443 + } 1444 + 1445 + // Capability badges 1446 + const caps = []; 1447 + if (agent.schedule) caps.push(agent.schedule); 1448 + if (agent.type === 'cogitate') caps.push('tools'); 1449 + if (agent.type === 'generate') caps.push('output'); 1450 + if (agent.multi_facet) caps.push('per-facet'); 1451 + 1452 + if (caps.length > 0) { 1453 + const capsRow = document.createElement('div'); 1454 + capsRow.className = 'agent-card-caps'; 1455 + for (const cap of caps) { 1456 + const pill = document.createElement('span'); 1457 + pill.className = 'cap-badge'; 1458 + pill.textContent = cap; 1459 + capsRow.appendChild(pill); 1460 + } 1461 + card.appendChild(capsRow); 1462 + } 1463 + 1464 + // Activity row: counts on left, facet dots on right 1465 + const hasActivity = agent.thinking_count > 0 || agent.tool_count > 0 || agent.total_cost > 0; 1466 + const facetNames = Array.from(agent.facets); 1467 + const hasFacetDots = facetNames.length > 0; 1468 + 1469 + if (hasActivity || hasFacetDots) { 1470 + const activity = document.createElement('div'); 1471 + activity.className = 'agent-card-activity'; 1472 + 1473 + const counts = document.createElement('div'); 1474 + counts.className = 'activity-counts'; 1475 + 1476 + if (agent.thinking_count > 0) { 1477 + const thinking = document.createElement('span'); 1478 + thinking.className = 'activity-item'; 1479 + thinking.innerHTML = `💭 ${agent.thinking_count}`; 1480 + thinking.title = `${agent.thinking_count} thinking events`; 1481 + counts.appendChild(thinking); 1482 + } 1483 + 1484 + if (agent.tool_count > 0) { 1485 + const tools = document.createElement('span'); 1486 + tools.className = 'activity-item'; 1487 + tools.innerHTML = `🔧 ${agent.tool_count}`; 1488 + tools.title = `${agent.tool_count} tool calls`; 1489 + counts.appendChild(tools); 1490 + } 1491 + 1492 + if (agent.total_cost > 0) { 1493 + const costInfo = formatCost(agent.total_cost); 1494 + const cost = document.createElement('span'); 1495 + cost.className = 'activity-item'; 1496 + cost.innerHTML = `💰 ${costInfo.text}`; 1497 + cost.title = costInfo.title; 1498 + counts.appendChild(cost); 1499 + } 1500 + 1501 + activity.appendChild(counts); 1502 + 1503 + if (hasFacetDots) { 1504 + const dots = document.createElement('div'); 1505 + dots.className = 'facet-dots'; 1506 + 1507 + for (const facetName of facetNames) { 1508 + const facet = facetsMeta[facetName]; 1509 + const dot = document.createElement('span'); 1510 + dot.className = 'facet-dot'; 1511 + dot.style.backgroundColor = (facet && facet.color) || '#999'; 1512 + const label = (facet && facet.title) || facetName; 1513 + dot.setAttribute('aria-label', label); 1514 + dot.style.fontSize = '7px'; 1515 + dot.style.color = 'white'; 1516 + dot.style.fontWeight = 'bold'; 1517 + dot.style.lineHeight = '10px'; 1518 + dot.style.textAlign = 'center'; 1519 + dot.appendChild(document.createTextNode(label.charAt(0).toUpperCase())); 1520 + dots.appendChild(dot); 1521 + } 1522 + 1523 + activity.appendChild(dots); 1524 + } 1525 + 1526 + card.appendChild(activity); 1527 + } 1528 + 1529 + return card; 1530 + } 1531 + 1532 + // ========================================================================= 1533 + // Run List View 1534 + // ========================================================================= 1535 + 1536 + function showRunList(name) { 1537 + currentName = name; 1538 + currentRunId = null; 1539 + cachedRunDetail = null; 1540 + runOutputLoaded = false; 1541 + runOutputLoading = false; 1542 + 1543 + // Update hash without re-triggering if already correct 1544 + const encodedName = encodeURIComponent(name); 1545 + if (location.hash.slice(1) !== encodedName) { 1546 + location.hash = encodedName; 1547 + return; // hashchange will call us back 1548 + } 1549 + 1550 + hideAllViews(); 1551 + document.getElementById('list-view').style.display = 'block'; 1552 + 1553 + // Filter runs client-side 1554 + const runs = allRuns.filter(r => r.name === name); 1555 + const meta = agentsMeta[name] || {}; 1556 + const title = meta.title || name; 1557 + const failedCount = runs.filter(r => r.failed).length; 1558 + 1559 + document.getElementById('list-view-title').textContent = title; 1560 + document.getElementById('list-view-count').textContent = 1561 + `${runs.length} run${runs.length !== 1 ? 's' : ''}` + 1562 + (failedCount > 0 ? ` (${failedCount} failed)` : ''); 1563 + 1564 + renderRunList(runs); 1565 + } 1566 + 1567 + // Render the run list table 1568 + function renderRunList(runs) { 1569 + const tbody = document.getElementById('runs-tbody'); 1570 + tbody.innerHTML = ''; 1571 + 1572 + if (runs.length === 0) { 1573 + tbody.innerHTML = '<tr><td colspan="11" class="run-loading">No runs found</td></tr>'; 1574 + return; 1575 + } 1576 + 1577 + for (const run of runs) { 1578 + const row = document.createElement('tr'); 1579 + row.dataset.runId = run.id; 1580 + row.onclick = () => showRunDetail(run.id, run.name); 1581 + row.setAttribute('tabindex', '0'); 1582 + row.addEventListener('keydown', e => { 1583 + if (e.key === 'Enter') { 1584 + showRunDetail(run.id, run.name); 1585 + } else if (e.key === 'ArrowDown') { 1586 + e.preventDefault(); 1587 + const next = row.nextElementSibling; 1588 + if (next) next.focus(); 1589 + } else if (e.key === 'ArrowUp') { 1590 + e.preventDefault(); 1591 + const prev = row.previousElementSibling; 1592 + if (prev) prev.focus(); 1593 + } else if (e.key === 'Home') { 1594 + e.preventDefault(); 1595 + const tbody = row.closest('tbody'); 1596 + if (tbody && tbody.firstElementChild) tbody.firstElementChild.focus(); 1597 + } else if (e.key === 'End') { 1598 + e.preventDefault(); 1599 + const tbody = row.closest('tbody'); 1600 + if (tbody && tbody.lastElementChild) tbody.lastElementChild.focus(); 1601 + } 1602 + }); 1603 + 1604 + // Status 1605 + const statusCell = document.createElement('td'); 1606 + statusCell.className = 'col-status'; 1607 + const statusIcon = document.createElement('span'); 1608 + statusIcon.className = 'status-icon'; 1609 + if (run.status === 'running') { 1610 + statusIcon.className += ' status-running'; 1611 + statusIcon.textContent = '\u23f3'; 1612 + statusIcon.title = 'Running'; 1613 + } else if (run.failed) { 1614 + statusIcon.className += ' status-failed'; 1615 + statusIcon.textContent = '\u2717'; 1616 + statusIcon.title = 'Failed'; 1617 + } else { 1618 + statusIcon.className += ' status-ok'; 1619 + statusIcon.textContent = '\u2713'; 1620 + statusIcon.title = 'Completed'; 1621 + } 1622 + statusCell.appendChild(statusIcon); 1623 + row.appendChild(statusCell); 1624 + 1625 + // Time 1626 + const timeCell = document.createElement('td'); 1627 + timeCell.className = 'col-time'; 1628 + timeCell.textContent = formatTime(run.start); 1629 + row.appendChild(timeCell); 1630 + 1631 + // Model 1632 + const modelCell = document.createElement('td'); 1633 + modelCell.className = 'col-model'; 1634 + const modelSpan = document.createElement('span'); 1635 + modelSpan.className = 'model-name'; 1636 + modelSpan.textContent = formatModel(run.model); 1637 + modelSpan.title = run.model || ''; 1638 + modelCell.appendChild(modelSpan); 1639 + row.appendChild(modelCell); 1640 + 1641 + // Provider 1642 + const providerCell = document.createElement('td'); 1643 + providerCell.className = 'col-provider'; 1644 + const providerSpan = document.createElement('span'); 1645 + providerSpan.className = 'provider-name'; 1646 + providerSpan.textContent = run.provider || '\u2014'; 1647 + providerCell.appendChild(providerSpan); 1648 + row.appendChild(providerCell); 1649 + 1650 + // Runtime 1651 + const runtimeCell = document.createElement('td'); 1652 + runtimeCell.className = 'col-runtime'; 1653 + runtimeCell.textContent = formatRuntime(run.runtime_seconds); 1654 + row.appendChild(runtimeCell); 1655 + 1656 + // Thinking count 1657 + const thinkingCell = document.createElement('td'); 1658 + thinkingCell.className = 'col-activity'; 1659 + thinkingCell.textContent = run.thinking_count || 0; 1660 + row.appendChild(thinkingCell); 1661 + 1662 + // Tool count 1663 + const toolCell = document.createElement('td'); 1664 + toolCell.className = 'col-activity'; 1665 + toolCell.textContent = run.tool_count || 0; 1666 + row.appendChild(toolCell); 1667 + 1668 + // Cost 1669 + const costCell = document.createElement('td'); 1670 + costCell.className = 'col-activity'; 1671 + const costInfo = formatCost(run.cost); 1672 + costCell.textContent = costInfo.text; 1673 + if (costInfo.title) costCell.title = costInfo.title; 1674 + row.appendChild(costCell); 1675 + 1676 + // Facet 1677 + const facetCell = document.createElement('td'); 1678 + facetCell.className = 'col-facet'; 1679 + if (run.facet) { 1680 + const facet = facetsMeta[run.facet]; 1681 + const tag = document.createElement('span'); 1682 + tag.className = 'facet-tag'; 1683 + if (facet && facet.color) { 1684 + const dot = document.createElement('span'); 1685 + dot.className = 'facet-dot'; 1686 + dot.style.backgroundColor = facet.color; 1687 + dot.setAttribute('aria-label', facet.title || run.facet); 1688 + tag.appendChild(dot); 1689 + } 1690 + tag.appendChild(document.createTextNode((facet && facet.title) || run.facet)); 1691 + facetCell.appendChild(tag); 1692 + } else { 1693 + facetCell.textContent = '\u2014'; 1694 + } 1695 + row.appendChild(facetCell); 1696 + 1697 + // Output file 1698 + const outputCell = document.createElement('td'); 1699 + outputCell.className = 'col-output'; 1700 + if (run.output_file) { 1701 + outputCell.textContent = run.output_file.split('/').pop(); 1702 + outputCell.title = run.output_file; 1703 + } else { 1704 + outputCell.textContent = '\u2014'; 1705 + } 1706 + row.appendChild(outputCell); 1707 + 1708 + // Prompt + error preview 1709 + const promptCell = document.createElement('td'); 1710 + promptCell.className = 'col-prompt'; 1711 + const promptSpan = document.createElement('span'); 1712 + promptSpan.className = 'prompt-snippet'; 1713 + promptSpan.textContent = truncate(run.prompt); 1714 + promptSpan.title = run.prompt || ''; 1715 + promptCell.appendChild(promptSpan); 1716 + 1717 + if (run.failed && run.error_message) { 1718 + const errorDiv = document.createElement('div'); 1719 + errorDiv.className = 'error-preview'; 1720 + errorDiv.textContent = run.error_message; 1721 + errorDiv.title = run.error_message; 1722 + promptCell.appendChild(errorDiv); 1723 + } 1724 + 1725 + row.appendChild(promptCell); 1726 + 1727 + tbody.appendChild(row); 1728 + } 1729 + } 1730 + 1731 + async function showRunDetail(runId, agentName) { 1732 + currentName = agentName; 1733 + currentRunId = runId; 1734 + cachedRunDetail = null; 1735 + runOutputLoaded = false; 1736 + runOutputLoading = false; 1737 + 1738 + const targetHash = `${encodeURIComponent(agentName)}/${encodeURIComponent(runId)}`; 1739 + if (location.hash.slice(1) !== targetHash) { 1740 + location.hash = targetHash; 1741 + return; 1742 + } 1743 + 1744 + hideAllViews(); 1745 + document.getElementById('run-detail-view').style.display = 'block'; 1746 + 1747 + const crumbAgent = document.getElementById('crumb-agent'); 1748 + crumbAgent.textContent = formatAgentDisplayName(agentName); 1749 + crumbAgent.onclick = (e) => { 1750 + e.preventDefault() 1751 + showRunList(agentName) 1752 + }; 1753 + document.getElementById('crumb-run').textContent = 'Run ...'; 1754 + 1755 + document.getElementById('run-detail-header-mount').innerHTML = 1756 + '<div class="run-loading"><div class="spinner"></div><div>Loading run details...</div></div>'; 1757 + document.getElementById('run-detail-log-pane').innerHTML = 1758 + '<div class="run-loading"><div class="spinner"></div><div>Loading run log...</div></div>'; 1759 + document.getElementById('run-detail-output-pane').innerHTML = ''; 1760 + document.getElementById('tab-output').style.display = 'none'; 1761 + activateRunDetailTab('log'); 1762 + 1763 + try { 1764 + const response = await fetch(`api/run/${encodeURIComponent(runId)}`); 1765 + const data = await response.json(); 1766 + 1767 + if (currentRunId !== runId) return; 1768 + 1769 + if (response.status === 202) { 1770 + renderRunDetailMessage(data.error || 'Run is still in progress'); 1771 + return; 1772 + } 1773 + 1774 + if (data.error) { 1775 + renderRunDetailMessage(`Unable to load run details — ${data.error}. Go back and select the run again.`); 1776 + return; 1777 + } 1778 + 1779 + renderRunDetail(data, agentName); 1780 + } catch (error) { 1781 + if (currentRunId !== runId) return; 1782 + renderRunDetailMessage('Unable to load run details. The server may be unreachable — check your connection and try again.'); 1783 + } 1784 + } 1785 + 1786 + function renderRunDetailMessage(message) { 1787 + document.getElementById('run-detail-header-mount').innerHTML = ''; 1788 + const logPane = document.getElementById('run-detail-log-pane'); 1789 + logPane.innerHTML = ''; 1790 + const msg = document.createElement('div'); 1791 + msg.className = 'run-loading'; 1792 + msg.setAttribute('aria-live', 'polite'); 1793 + msg.textContent = message; 1794 + logPane.appendChild(msg); 1795 + document.getElementById('run-detail-output-pane').innerHTML = ''; 1796 + document.getElementById('tab-output').style.display = 'none'; 1797 + activateRunDetailTab('log'); 1798 + } 1799 + 1800 + function renderRunDetail(data, agentName) { 1801 + cachedRunDetail = data; 1802 + runOutputLoaded = false; 1803 + runOutputLoading = false; 1804 + 1805 + const displayName = formatAgentDisplayName(agentName); 1806 + const crumbAgent = document.getElementById('crumb-agent'); 1807 + crumbAgent.textContent = displayName; 1808 + crumbAgent.onclick = (e) => { 1809 + e.preventDefault() 1810 + showRunList(agentName) 1811 + }; 1812 + document.getElementById('crumb-run').textContent = `Run ${formatTime(data.start)}`; 1813 + 1814 + const headerMount = document.getElementById('run-detail-header-mount'); 1815 + headerMount.innerHTML = ''; 1816 + headerMount.appendChild(renderRunDetailHeader(data)); 1817 + 1818 + const logPane = document.getElementById('run-detail-log-pane'); 1819 + logPane.innerHTML = ''; 1820 + const events = Array.isArray(data.events) ? data.events : []; 1821 + if (events.length > 0) { 1822 + logPane.appendChild(renderEventTimeline(events)); 1823 + } else { 1824 + const empty = document.createElement('div'); 1825 + empty.className = 'run-loading'; 1826 + empty.textContent = 'No log events recorded for this run'; 1827 + logPane.appendChild(empty); 1828 + } 1829 + 1830 + const outputTab = document.getElementById('tab-output'); 1831 + outputTab.style.display = data.output_file ? 'inline-flex' : 'none'; 1832 + document.getElementById('run-detail-output-pane').innerHTML = ''; 1833 + activateRunDetailTab('log'); 1834 + } 1835 + 1836 + function renderRunDetailHeader(data) { 1837 + const card = document.createElement('div'); 1838 + card.className = 'run-detail-header-card'; 1839 + 1840 + const titleRow = document.createElement('div'); 1841 + titleRow.className = 'run-detail-title-row'; 1842 + 1843 + const status = document.createElement('span'); 1844 + status.className = 'run-status-badge'; 1845 + if (data.status === 'running') { 1846 + status.classList.add('status-running'); 1847 + status.textContent = 'running'; 1848 + } else if (data.failed) { 1849 + status.classList.add('status-failed'); 1850 + status.textContent = 'failed'; 1851 + } else { 1852 + status.classList.add('status-completed'); 1853 + status.textContent = 'completed'; 1854 + } 1855 + titleRow.appendChild(status); 1856 + 1857 + const title = document.createElement('h2'); 1858 + title.className = 'run-detail-title'; 1859 + title.textContent = `Run ${formatTime(data.start)}`; 1860 + titleRow.appendChild(title); 1861 + 1862 + if (data.facet) { 1863 + const facet = facetsMeta[data.facet]; 1864 + const tag = document.createElement('span'); 1865 + tag.className = 'facet-tag'; 1866 + if (facet && facet.color) { 1867 + const dot = document.createElement('span'); 1868 + dot.className = 'facet-dot'; 1869 + dot.style.backgroundColor = facet.color; 1870 + dot.setAttribute('aria-label', (facet && facet.title) || data.facet); 1871 + tag.appendChild(dot); 1872 + } 1873 + tag.appendChild(document.createTextNode((facet && facet.title) || data.facet)); 1874 + titleRow.appendChild(tag); 1875 + } 1876 + 1877 + card.appendChild(titleRow); 1878 + 1879 + const subtitle = document.createElement('div'); 1880 + subtitle.className = 'run-detail-subtitle'; 1881 + subtitle.textContent = formatAgentDisplayName(data.name || currentName); 1882 + card.appendChild(subtitle); 1883 + 1884 + if (data.prompt) { 1885 + const prompt = document.createElement('div'); 1886 + prompt.className = 'run-detail-prompt'; 1887 + prompt.textContent = data.prompt; 1888 + card.appendChild(prompt); 1889 + } 1890 + 1891 + const grid = document.createElement('div'); 1892 + grid.className = 'run-meta-grid'; 1893 + 1894 + function addMetaItem(label, value, titleText = '') { 1895 + const item = document.createElement('div'); 1896 + item.className = 'run-meta-item'; 1897 + const labelEl = document.createElement('span'); 1898 + labelEl.className = 'meta-label'; 1899 + labelEl.textContent = label; 1900 + const valueEl = document.createElement('span'); 1901 + valueEl.className = 'meta-value'; 1902 + valueEl.textContent = value; 1903 + if (titleText) valueEl.title = titleText; 1904 + item.appendChild(labelEl); 1905 + item.appendChild(valueEl); 1906 + grid.appendChild(item); 1907 + } 1908 + 1909 + addMetaItem('started', formatTimestamp(data.start) || '\u2014'); 1910 + addMetaItem('runtime', formatRuntime(data.runtime_seconds)); 1911 + addMetaItem('model', formatModel(data.model), data.model || ''); 1912 + addMetaItem('provider', data.provider || '\u2014'); 1913 + addMetaItem('thinking', String(data.thinking_count || 0)); 1914 + addMetaItem('tools', String(data.tool_count || 0)); 1915 + const costInfo = formatCost(data.cost); 1916 + addMetaItem('cost', costInfo.text, costInfo.title); 1917 + 1918 + card.appendChild(grid); 1919 + return card; 1920 + } 1921 + 1922 + function activateRunDetailTab(tabId) { 1923 + const tabs = document.querySelectorAll('#run-detail-tabs .rd-tab'); 1924 + for (const tab of tabs) { 1925 + tab.classList.toggle('active', tab.dataset.tab === tabId); 1926 + tab.setAttribute('aria-selected', tab.dataset.tab === tabId ? 'true' : 'false'); 1927 + tab.setAttribute('tabindex', tab.dataset.tab === tabId ? '0' : '-1'); 1928 + } 1929 + 1930 + document.getElementById('run-detail-log-pane').classList.toggle('active', tabId === 'log'); 1931 + document.getElementById('run-detail-output-pane').classList.toggle('active', tabId === 'output'); 1932 + 1933 + if (tabId === 'output') { 1934 + loadRunOutput(); 1935 + } 1936 + } 1937 + 1938 + async function loadRunOutput() { 1939 + if ( 1940 + !cachedRunDetail || 1941 + !cachedRunDetail.day || 1942 + !cachedRunDetail.output_file || 1943 + runOutputLoaded || 1944 + runOutputLoading 1945 + ) { 1946 + return; 1947 + } 1948 + 1949 + runOutputLoading = true; 1950 + const pane = document.getElementById('run-detail-output-pane'); 1951 + pane.innerHTML = '<div class="run-loading"><div class="spinner"></div><div>Loading output...</div></div>'; 1952 + 1953 + const encodedFilename = cachedRunDetail.output_file 1954 + .split('/') 1955 + .map(part => encodeURIComponent(part)) 1956 + .join('/'); 1957 + 1958 + try { 1959 + const response = await fetch(`api/output/${cachedRunDetail.day}/${encodedFilename}`); 1960 + const data = await response.json(); 1961 + 1962 + if (data.error) { 1963 + pane.innerHTML = `<div class="run-loading" aria-live="polite">Unable to load output — ${escapeHtml(data.error)}. Try switching tabs or reloading the page.</div>`; 1964 + return; 1965 + } 1966 + 1967 + const container = document.createElement('div'); 1968 + container.className = 'run-output-content'; 1969 + 1970 + if (data.format === 'md') { 1971 + const markdown = document.createElement('div'); 1972 + markdown.className = 'rendered-markdown'; 1973 + markdown.innerHTML = sanitizeHtml(marked.parse(data.content, { breaks: true, gfm: true })); 1974 + container.appendChild(markdown); 1975 + } else { 1976 + const pre = document.createElement('pre'); 1977 + if (data.format === 'json') { 1978 + try { 1979 + pre.textContent = JSON.stringify(JSON.parse(data.content), null, 2); 1980 + } catch (e) { 1981 + pre.textContent = data.content; 1982 + } 1983 + } else { 1984 + pre.textContent = data.content; 1985 + } 1986 + container.appendChild(pre); 1987 + } 1988 + 1989 + pane.innerHTML = ''; 1990 + pane.appendChild(container); 1991 + runOutputLoaded = true; 1992 + } catch (error) { 1993 + pane.innerHTML = '<div class="run-loading" aria-live="polite">Unable to load output file. The server may be unreachable — check your connection and try again.</div>'; 1994 + } finally { 1995 + runOutputLoading = false; 1996 + } 1997 + } 1998 + 1999 + // Format a millisecond timestamp to HH:MM:SS 2000 + function formatTimestamp(ts) { 2001 + if (!ts) return ''; 2002 + const d = new Date(ts); 2003 + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); 2004 + } 2005 + 2006 + // Escape HTML to prevent XSS 2007 + function escapeHtml(text) { 2008 + const div = document.createElement('div'); 2009 + div.textContent = text; 2010 + return div.innerHTML; 2011 + } 2012 + 2013 + // Sanitize HTML to allow only safe tags and attributes 2014 + function sanitizeHtml(html) { 2015 + const doc = new DOMParser().parseFromString(html, 'text/html'); 2016 + const allowedTags = new Set([ 2017 + 'p', 'strong', 'em', 'code', 'pre', 'ul', 'ol', 'li', 'a', 2018 + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'br', 'hr', 2019 + 'table', 'thead', 'tbody', 'tr', 'th', 'td' 2020 + ]); 2021 + const allowedAttributes = { 2022 + a: new Set(['href']) 2023 + }; 2024 + 2025 + Array.from(doc.body.querySelectorAll('*')).forEach(function(el) { 2026 + const tagName = el.tagName.toLowerCase(); 2027 + if (!allowedTags.has(tagName)) { 2028 + el.replaceWith(doc.createTextNode(el.textContent || '')); 2029 + return; 2030 + } 2031 + 2032 + Array.from(el.attributes).forEach(function(attr) { 2033 + if (!allowedAttributes[tagName] || !allowedAttributes[tagName].has(attr.name)) { 2034 + el.removeAttribute(attr.name); 2035 + } 2036 + }); 2037 + 2038 + if (tagName === 'a') { 2039 + const href = el.getAttribute('href'); 2040 + if (!href || !/^(https?:\/\/|mailto:)/.test(href)) { 2041 + el.removeAttribute('href'); 2042 + } 2043 + el.setAttribute('target', '_blank'); 2044 + el.setAttribute('rel', 'noopener noreferrer'); 2045 + } 2046 + }); 2047 + 2048 + return doc.body.innerHTML; 2049 + } 2050 + 2051 + // Render agent events as a rich interactive timeline 2052 + function renderEventTimeline(events) { 2053 + const timeline = document.createElement('div'); 2054 + timeline.className = 'event-timeline'; 2055 + 2056 + // First pass: index tool_end events by call_id for pairing 2057 + const toolEnds = {}; 2058 + for (const event of events) { 2059 + if (event.event === 'tool_end' && event.call_id) { 2060 + toolEnds[event.call_id] = event; 2061 + } 2062 + } 2063 + 2064 + // Second pass: render in chronological order 2065 + for (const event of events) { 2066 + const type = event.event; 2067 + if (!type) continue; 2068 + 2069 + // Skip request/start events (metadata, shown in header already) 2070 + if (type === 'request' || type === 'start') continue; 2071 + 2072 + // Skip tool_end with call_id (rendered with its tool_start) 2073 + if (type === 'tool_end' && event.call_id && toolEnds[event.call_id]) continue; 2074 + 2075 + if (type === 'thinking') { 2076 + timeline.appendChild(renderThinkingEvent(event)); 2077 + } else if (type === 'tool_start') { 2078 + const endEvent = event.call_id ? toolEnds[event.call_id] : null; 2079 + timeline.appendChild(renderToolEvent(event, endEvent || null)); 2080 + } else if (type === 'tool_end') { 2081 + // Orphaned tool_end (no call_id or unmatched) 2082 + timeline.appendChild(renderToolEvent(null, event)); 2083 + } else if (type === 'error') { 2084 + timeline.appendChild(renderErrorEvent(event)); 2085 + } else if (type === 'info') { 2086 + timeline.appendChild(renderInfoEvent(event)); 2087 + } else if (type === 'finish') { 2088 + timeline.appendChild(renderFinishEvent(event)); 2089 + } else if (type === 'agent_updated') { 2090 + timeline.appendChild(renderFlowEvent('Switched to agent: ' + (event.agent || 'unknown'), event.ts)); 2091 + } else if (type === 'continue') { 2092 + timeline.appendChild(renderFlowEvent('Continued in agent: ' + (event.to || 'unknown'), event.ts)); 2093 + } 2094 + } 2095 + 2096 + return timeline; 2097 + } 2098 + 2099 + function renderThinkingEvent(event) { 2100 + const block = document.createElement('div'); 2101 + block.className = 'event-block event-thinking'; 2102 + 2103 + const text = event.summary || event.content || ''; 2104 + block.innerHTML = 2105 + '<span class="event-time">' + escapeHtml(formatTimestamp(event.ts)) + '</span>' + 2106 + '<span class="event-label">thinking</span>' + 2107 + (text ? '<div class="thinking-text">' + escapeHtml(text) + '</div>' : ''); 2108 + 2109 + return block; 2110 + } 2111 + 2112 + function renderToolEvent(startEvent, endEvent) { 2113 + const block = document.createElement('div'); 2114 + block.className = 'event-block event-tool'; 2115 + 2116 + const toolName = (startEvent && startEvent.tool) || (endEvent && endEvent.tool) || 'unknown'; 2117 + const ts = (startEvent && startEvent.ts) || (endEvent && endEvent.ts) || 0; 2118 + const args = (startEvent && startEvent.args) || (endEvent && endEvent.args) || null; 2119 + const result = endEvent && endEvent.result; 2120 + const hasDetails = args || result; 2121 + const incomplete = startEvent && !endEvent; 2122 + 2123 + // Header line with tool name and toggle 2124 + const headerLine = document.createElement('div'); 2125 + headerLine.innerHTML = 2126 + '<span class="event-time">' + escapeHtml(formatTimestamp(ts)) + '</span>' + 2127 + '<span class="event-label">tool</span> ' + 2128 + '<span class="tool-name">' + escapeHtml(toolName) + '</span>' + 2129 + (incomplete ? '<span class="tool-incomplete-badge">did not complete</span>' : ''); 2130 + 2131 + if (hasDetails) { 2132 + const toggle = document.createElement('button'); 2133 + toggle.className = 'tool-toggle'; 2134 + toggle.textContent = 'show'; 2135 + toggle.onclick = function(e) { 2136 + e.stopPropagation(); 2137 + const details = block.querySelector('.tool-details'); 2138 + if (details.style.display === 'none') { 2139 + details.style.display = 'block'; 2140 + toggle.textContent = 'hide'; 2141 + } else { 2142 + details.style.display = 'none'; 2143 + toggle.textContent = 'show'; 2144 + } 2145 + }; 2146 + headerLine.appendChild(toggle); 2147 + } 2148 + 2149 + block.appendChild(headerLine); 2150 + 2151 + // Collapsible details section 2152 + if (hasDetails || incomplete) { 2153 + const details = document.createElement('div'); 2154 + details.className = 'tool-details'; 2155 + details.style.display = 'none'; 2156 + 2157 + if (args && Object.keys(args).length > 0) { 2158 + const argsLabel = document.createElement('div'); 2159 + argsLabel.className = 'tool-section-label'; 2160 + argsLabel.textContent = 'args'; 2161 + details.appendChild(argsLabel); 2162 + const argsPre = document.createElement('pre'); 2163 + argsPre.textContent = JSON.stringify(args, null, 2); 2164 + details.appendChild(argsPre); 2165 + } 2166 + 2167 + if (result) { 2168 + const resultLabel = document.createElement('div'); 2169 + resultLabel.className = 'tool-section-label'; 2170 + resultLabel.textContent = 'result'; 2171 + details.appendChild(resultLabel); 2172 + const resultPre = document.createElement('pre'); 2173 + resultPre.textContent = result; 2174 + details.appendChild(resultPre); 2175 + } 2176 + 2177 + if (incomplete) { 2178 + const note = document.createElement('div'); 2179 + note.className = 'tool-incomplete'; 2180 + note.textContent = 'Tool call did not complete'; 2181 + details.appendChild(note); 2182 + } 2183 + 2184 + block.appendChild(details); 2185 + } 2186 + 2187 + return block; 2188 + } 2189 + 2190 + function renderErrorEvent(event) { 2191 + const block = document.createElement('div'); 2192 + block.className = 'event-block event-error'; 2193 + 2194 + let html = 2195 + '<span class="event-time">' + escapeHtml(formatTimestamp(event.ts)) + '</span>' + 2196 + '<span class="event-label">error</span> ' + 2197 + '<span class="error-message">' + escapeHtml(event.error || 'Unknown error') + '</span>'; 2198 + 2199 + if (event.trace) { 2200 + html += '<div class="error-trace"><pre>' + escapeHtml(event.trace) + '</pre></div>'; 2201 + } 2202 + 2203 + block.innerHTML = html; 2204 + return block; 2205 + } 2206 + 2207 + function renderInfoEvent(event) { 2208 + const block = document.createElement('div'); 2209 + block.className = 'event-block event-info'; 2210 + block.innerHTML = 2211 + '<span class="event-time">' + escapeHtml(formatTimestamp(event.ts)) + '</span>' + 2212 + escapeHtml(event.message || ''); 2213 + return block; 2214 + } 2215 + 2216 + function renderFinishEvent(event) { 2217 + const block = document.createElement('div'); 2218 + block.className = 'event-block event-finish'; 2219 + 2220 + const timeSpan = document.createElement('span'); 2221 + timeSpan.className = 'event-time'; 2222 + timeSpan.textContent = formatTimestamp(event.ts); 2223 + block.appendChild(timeSpan); 2224 + 2225 + const label = document.createElement('span'); 2226 + label.className = 'event-label'; 2227 + label.textContent = 'result'; 2228 + block.appendChild(label); 2229 + 2230 + if (event.result && event.result.trim()) { 2231 + const resultDiv = document.createElement('div'); 2232 + resultDiv.className = 'finish-result'; 2233 + resultDiv.innerHTML = sanitizeHtml(marked.parse(event.result, { breaks: true, gfm: true })); 2234 + block.appendChild(resultDiv); 2235 + } else { 2236 + const empty = document.createElement('div'); 2237 + empty.className = 'finish-empty'; 2238 + empty.textContent = 'No output'; 2239 + block.appendChild(empty); 2240 + } 2241 + 2242 + return block; 2243 + } 2244 + 2245 + function renderFlowEvent(text, ts) { 2246 + const block = document.createElement('div'); 2247 + block.className = 'event-block event-flow'; 2248 + block.innerHTML = 2249 + '<span class="event-time">' + escapeHtml(formatTimestamp(ts)) + '</span>' + 2250 + '<span class="flow-label">' + escapeHtml(text) + '</span>'; 2251 + return block; 2252 + } 2253 + 2254 + // ========================================================================= 2255 + // Navigation & Preview Modal 2256 + // ========================================================================= 2257 + 2258 + window.loadAgents = loadAgents; 2259 + 2260 + window.showGridView = function() { 2261 + currentName = null; 2262 + currentRunId = null; 2263 + cachedRunDetail = null; 2264 + runOutputLoaded = false; 2265 + runOutputLoading = false; 2266 + if (location.hash) { 2267 + location.hash = ''; 2268 + return; 2269 + } 2270 + handleHashChange(); 2271 + }; 2272 + 2273 + // Hash-based routing 2274 + window.addEventListener('hashchange', handleHashChange); 2275 + 2276 + function handleHashChange() { 2277 + const hash = location.hash.startsWith('#') ? location.hash.slice(1) : location.hash; 2278 + if (!hash) { 2279 + renderGridView(); 2280 + return; 2281 + } 2282 + 2283 + try { 2284 + const slashIndex = hash.indexOf('/'); 2285 + if (slashIndex === -1) { 2286 + showRunList(decodeURIComponent(hash)); 2287 + return; 2288 + } 2289 + 2290 + const namePart = hash.slice(0, slashIndex); 2291 + const runPart = hash.slice(slashIndex + 1); 2292 + if (!namePart || !runPart) { 2293 + renderGridView(); 2294 + return; 2295 + } 2296 + 2297 + showRunDetail(decodeURIComponent(runPart), decodeURIComponent(namePart)); 2298 + } catch (_error) { 2299 + renderGridView(); 2300 + } 2301 + } 2302 + 2303 + document.getElementById('run-detail-tabs').addEventListener('click', function(e) { 2304 + const tab = e.target.closest('.rd-tab'); 2305 + if (!tab) return; 2306 + activateRunDetailTab(tab.dataset.tab); 2307 + }); 2308 + 2309 + document.getElementById('run-detail-tabs').addEventListener('keydown', function(e) { 2310 + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return; 2311 + e.preventDefault(); 2312 + const visibleTabs = Array.from(this.querySelectorAll('.rd-tab')).filter(t => t.offsetParent !== null); 2313 + if (visibleTabs.length === 0) return; 2314 + const currentIndex = visibleTabs.indexOf(document.activeElement); 2315 + if (currentIndex === -1) return; 2316 + let newIndex; 2317 + switch (e.key) { 2318 + case 'ArrowRight': newIndex = (currentIndex + 1) % visibleTabs.length; break; 2319 + case 'ArrowLeft': newIndex = (currentIndex - 1 + visibleTabs.length) % visibleTabs.length; break; 2320 + case 'Home': newIndex = 0; break; 2321 + case 'End': newIndex = visibleTabs.length - 1; break; 2322 + } 2323 + visibleTabs[newIndex].focus(); 2324 + activateRunDetailTab(visibleTabs[newIndex].dataset.tab); 2325 + }); 2326 + 2327 + window.showPreview = async function() { 2328 + if (!currentName) return; 2329 + 2330 + previousPreviewFocus = document.activeElement; 2331 + 2332 + const modal = document.getElementById('preview-modal'); 2333 + const content = document.getElementById('preview-modal-content'); 2334 + const title = document.getElementById('preview-modal-title'); 2335 + 2336 + content.textContent = 'Loading...'; 2337 + modal.classList.add('show'); 2338 + 2339 + // Focus the close button 2340 + const closeBtn = modal.querySelector('.modal-close'); 2341 + if (closeBtn) closeBtn.focus(); 2342 + 2343 + // Install focus trap (mirrors conversation panel pattern from app.html:230-246) 2344 + previewFocusTrapHandler = function(e) { 2345 + if (e.key !== 'Tab') return; 2346 + var focusable = Array.from( 2347 + modal.querySelectorAll('button, [href], input, select, textarea, [tabindex="0"]') 2348 + ).filter(function(el) { return el.offsetParent !== null && el.tabIndex >= 0 && !el.disabled; }); 2349 + if (focusable.length === 0) return; 2350 + var first = focusable[0]; 2351 + var last = focusable[focusable.length - 1]; 2352 + if (e.shiftKey && document.activeElement === first) { 2353 + e.preventDefault(); 2354 + last.focus(); 2355 + } else if (!e.shiftKey && document.activeElement === last) { 2356 + e.preventDefault(); 2357 + first.focus(); 2358 + } 2359 + }; 2360 + document.addEventListener('keydown', previewFocusTrapHandler); 2361 + 2362 + try { 2363 + const response = await fetch(`api/preview/${encodeURIComponent(currentName)}`); 2364 + const data = await response.json(); 2365 + 2366 + if (data.error) { 2367 + content.textContent = `Error: ${data.error}`; 2368 + } else { 2369 + title.textContent = `${data.title} - Prompt`; 2370 + content.textContent = data.full_prompt; 2371 + } 2372 + } catch (error) { 2373 + content.textContent = 'Error loading prompt'; 2374 + } 2375 + }; 2376 + 2377 + window.hidePreview = function() { 2378 + document.getElementById('preview-modal').classList.remove('show'); 2379 + 2380 + // Remove focus trap 2381 + if (previewFocusTrapHandler) { 2382 + document.removeEventListener('keydown', previewFocusTrapHandler); 2383 + previewFocusTrapHandler = null; 2384 + } 2385 + 2386 + // Restore focus (mirrors conversation panel pattern from app.html:273-278) 2387 + if (previousPreviewFocus && previousPreviewFocus.isConnected) { 2388 + previousPreviewFocus.focus(); 2389 + } else { 2390 + document.getElementById('preview-btn')?.focus(); 2391 + } 2392 + previousPreviewFocus = null; 2393 + }; 2394 + 2395 + // Close modal on backdrop click 2396 + document.getElementById('preview-modal').addEventListener('click', function(e) { 2397 + if (e.target === this) { 2398 + hidePreview(); 2399 + } 2400 + }); 2401 + 2402 + // Close modal on Escape 2403 + document.addEventListener('keydown', function(e) { 2404 + if (e.key === 'Escape' && document.getElementById('preview-modal').classList.contains('show')) { 2405 + hidePreview(); 2406 + } 2407 + }); 2408 + 2409 + // Listen for facet changes 2410 + window.addEventListener('facet.switch', () => { 2411 + loadUpdatedBanner(); 2412 + loadAgents(); 2413 + }); 2414 + 2415 + // Initial load 2416 + loadUpdatedBanner(); 2417 + loadAgents(); 2418 + })(); 2419 + </script>
+461
tests/baselines/api/agents/agents-day.json
··· 1 + { 2 + "agents": { 3 + "anticipation": { 4 + "app": null, 5 + "color": "#4527a0", 6 + "description": "Extracts structured anticipation events (future scheduled items) from insight summaries.", 7 + "multi_facet": false, 8 + "output_format": null, 9 + "schedule": null, 10 + "source": "system", 11 + "title": "Anticipation Extraction", 12 + "type": null 13 + }, 14 + "awareness_tender": { 15 + "app": null, 16 + "color": "#6c757d", 17 + "description": "Maintains sol/awareness.md — a compact situational awareness snapshot", 18 + "multi_facet": false, 19 + "output_format": null, 20 + "schedule": "segment", 21 + "source": "system", 22 + "title": "Awareness Tender", 23 + "type": "cogitate" 24 + }, 25 + "chat": { 26 + "app": null, 27 + "color": "#6c757d", 28 + "description": "Sol — the journal itself, as a conversational partner", 29 + "multi_facet": false, 30 + "output_format": null, 31 + "schedule": null, 32 + "source": "system", 33 + "title": "Sol", 34 + "type": "cogitate" 35 + }, 36 + "coder": { 37 + "app": null, 38 + "color": "#6c757d", 39 + "description": "Developer agent with full repo read/write access", 40 + "multi_facet": false, 41 + "output_format": null, 42 + "schedule": null, 43 + "source": "system", 44 + "title": "Coder", 45 + "type": "cogitate" 46 + }, 47 + "daily_schedule": { 48 + "app": null, 49 + "color": "#455a64", 50 + "description": "Analyzes activity patterns to identify optimal times for scheduled maintenance tasks.", 51 + "multi_facet": false, 52 + "output_format": "json", 53 + "schedule": "daily", 54 + "source": "system", 55 + "title": "Maintenance Window", 56 + "type": "generate" 57 + }, 58 + "decisionalizer": { 59 + "app": null, 60 + "color": "#c62828", 61 + "description": "Analyzes the day's top decision-actions to create detailed dossiers identifying gaps and stakeholder impacts", 62 + "multi_facet": false, 63 + "output_format": "md", 64 + "schedule": "daily", 65 + "source": "system", 66 + "title": "Decision Dossier Generator", 67 + "type": "cogitate" 68 + }, 69 + "decisions": { 70 + "app": null, 71 + "color": "#dc3545", 72 + "description": "Tracks consequential decision-actions that change state, plans, resources, responsibilities, or timing in ways that affect other people.", 73 + "multi_facet": false, 74 + "output_format": "md", 75 + "schedule": "activity", 76 + "source": "system", 77 + "title": "Decision Actions", 78 + "type": "generate" 79 + }, 80 + "entities": { 81 + "app": null, 82 + "color": "#2e7d32", 83 + "description": "Extracts people, companies, projects, and tools from segment content", 84 + "multi_facet": false, 85 + "output_format": "md", 86 + "schedule": "segment", 87 + "source": "system", 88 + "title": "Entity Extraction", 89 + "type": "generate" 90 + }, 91 + "entities:entities": { 92 + "app": "entities", 93 + "color": "#00897b", 94 + "description": "Mines journal for entity mentions and records facet-scoped detections with day-specific context", 95 + "multi_facet": true, 96 + "output_format": null, 97 + "schedule": "daily", 98 + "source": "app", 99 + "title": "Entity Detector", 100 + "type": "cogitate" 101 + }, 102 + "entities:entities_review": { 103 + "app": "entities", 104 + "color": "#00796b", 105 + "description": "Reviews detected entities and promotes recurring ones to attached status", 106 + "multi_facet": true, 107 + "output_format": null, 108 + "schedule": "daily", 109 + "source": "app", 110 + "title": "Entity Reviewer", 111 + "type": "cogitate" 112 + }, 113 + "entities:entity_assist": { 114 + "app": "entities", 115 + "color": "#00695c", 116 + "description": "Quick entity addition with intelligent type detection and automatic description generation", 117 + "multi_facet": false, 118 + "output_format": null, 119 + "schedule": null, 120 + "source": "app", 121 + "title": "Entity Assistant", 122 + "type": "cogitate" 123 + }, 124 + "entities:entity_describe": { 125 + "app": "entities", 126 + "color": "#26a69a", 127 + "description": "Research and generate single-sentence descriptions for attached entities", 128 + "multi_facet": false, 129 + "output_format": null, 130 + "schedule": null, 131 + "source": "app", 132 + "title": "Entity Description", 133 + "type": "cogitate" 134 + }, 135 + "entities:entity_observer": { 136 + "app": "entities", 137 + "color": "#004d40", 138 + "description": "Extracts durable factoids about attached entities from journal content", 139 + "multi_facet": true, 140 + "output_format": null, 141 + "schedule": "daily", 142 + "source": "app", 143 + "title": "Entity Observer", 144 + "type": "cogitate" 145 + }, 146 + "facet_newsletter": { 147 + "app": null, 148 + "color": "#0d47a1", 149 + "description": "Creates comprehensive daily newsletters for each facet, capturing activities, progress, and insights", 150 + "multi_facet": true, 151 + "output_format": null, 152 + "schedule": "daily", 153 + "source": "system", 154 + "title": "Facet Newsletter Generator", 155 + "type": "cogitate" 156 + }, 157 + "flow": { 158 + "app": null, 159 + "color": "#17a2b8", 160 + "description": "Summarizes the overall flow of the workday. Looks for patterns in focus, energy, context switching and highlights productivity insights in a Markdown report.", 161 + "multi_facet": false, 162 + "output_format": "md", 163 + "schedule": "daily", 164 + "source": "system", 165 + "title": "Day Overview", 166 + "type": "generate" 167 + }, 168 + "followups": { 169 + "app": null, 170 + "color": "#ffc107", 171 + "description": "Detects promised tasks, commitments, and reminders for future action within each activity. Outputs a concise Markdown list of follow-ups with context.", 172 + "multi_facet": false, 173 + "output_format": "md", 174 + "schedule": "activity", 175 + "source": "system", 176 + "title": "Follow-Up Items", 177 + "type": "generate" 178 + }, 179 + "heartbeat": { 180 + "app": null, 181 + "color": "#6c757d", 182 + "description": "Sol's periodic self-awareness — journal health, agency tending, curation scan", 183 + "multi_facet": false, 184 + "output_format": null, 185 + "schedule": "none", 186 + "source": "system", 187 + "title": "Heartbeat", 188 + "type": "cogitate" 189 + }, 190 + "joke_bot": { 191 + "app": null, 192 + "color": "#f9a825", 193 + "description": "Mines the analysis day's journal for poignant moments and crafts a personalized joke delivered via message", 194 + "multi_facet": false, 195 + "output_format": "md", 196 + "schedule": "daily", 197 + "source": "system", 198 + "title": "Joke Bot", 199 + "type": "cogitate" 200 + }, 201 + "knowledge_graph": { 202 + "app": null, 203 + "color": "#6f42c1", 204 + "description": "Extracts people, projects, tools and other entities from the transcript and maps how they relate. Produces a Markdown report plus narrative describing network hubs and bridges discovered during the day.", 205 + "multi_facet": false, 206 + "output_format": "md", 207 + "schedule": "daily", 208 + "source": "system", 209 + "title": "Knowledge Graph", 210 + "type": "generate" 211 + }, 212 + "meetings": { 213 + "app": null, 214 + "color": "#e83e8c", 215 + "description": "Produces detailed meeting notes for each meeting activity, including participants, topics discussed, action items, and presentation details.", 216 + "multi_facet": false, 217 + "output_format": "md", 218 + "schedule": "activity", 219 + "source": "system", 220 + "title": "Meeting Notes", 221 + "type": "generate" 222 + }, 223 + "messaging": { 224 + "app": null, 225 + "color": "#78909c", 226 + "description": "Extracts contacts, channels, apps, and message content from completed messaging and email activities.", 227 + "multi_facet": false, 228 + "output_format": "md", 229 + "schedule": "activity", 230 + "source": "system", 231 + "title": "Messaging Summary", 232 + "type": "generate" 233 + }, 234 + "morning_briefing": { 235 + "app": null, 236 + "color": "#1565c0", 237 + "description": "Synthesizes all daily agent outputs into a structured five-section morning briefing with entity intelligence", 238 + "multi_facet": false, 239 + "output_format": "md", 240 + "schedule": "daily", 241 + "source": "system", 242 + "title": "Morning Briefing", 243 + "type": "cogitate" 244 + }, 245 + "naming": { 246 + "app": null, 247 + "color": "#6c757d", 248 + "description": "Proposes a personalized name for the owner's journal assistant", 249 + "multi_facet": false, 250 + "output_format": null, 251 + "schedule": null, 252 + "source": "system", 253 + "title": "Naming", 254 + "type": "cogitate" 255 + }, 256 + "occurrence": { 257 + "app": null, 258 + "color": "#37474f", 259 + "description": "Extracts structured occurrence events from insight summaries.", 260 + "multi_facet": false, 261 + "output_format": null, 262 + "schedule": null, 263 + "source": "system", 264 + "title": "Occurrence Extraction", 265 + "type": null 266 + }, 267 + "partner": { 268 + "app": null, 269 + "color": "#6c757d", 270 + "description": "Weekly observation of the journal owner's behavioral patterns — work style, communication, priorities, decision-making, expertise", 271 + "multi_facet": false, 272 + "output_format": null, 273 + "schedule": "weekly", 274 + "source": "system", 275 + "title": "Partner Profile", 276 + "type": "cogitate" 277 + }, 278 + "pulse": { 279 + "app": null, 280 + "color": "#6c757d", 281 + "description": "Living narrative of the owner's day — updated each segment", 282 + "multi_facet": false, 283 + "output_format": null, 284 + "schedule": "segment", 285 + "source": "system", 286 + "title": "Pulse", 287 + "type": "cogitate" 288 + }, 289 + "routine": { 290 + "app": null, 291 + "color": "#6c757d", 292 + "description": "User-defined routine execution — runs owner instructions on schedule", 293 + "multi_facet": false, 294 + "output_format": null, 295 + "schedule": "none", 296 + "source": "system", 297 + "title": "Routine", 298 + "type": "cogitate" 299 + }, 300 + "schedule": { 301 + "app": null, 302 + "color": "#5e35b1", 303 + "description": "Identifies all future calendar events and scheduled activities noted in transcripts. Extracts dates, times, participants, and event details for anything scheduled beyond today.", 304 + "multi_facet": false, 305 + "output_format": "md", 306 + "schedule": "daily", 307 + "source": "system", 308 + "title": "Upcoming Schedule", 309 + "type": "generate" 310 + }, 311 + "screen": { 312 + "app": null, 313 + "color": "#9c27b0", 314 + "description": "Creates a detailed documentary record of screen activity. Focuses on the 'what' - chronological account with preserved details, excerpts, and entities.", 315 + "multi_facet": false, 316 + "output_format": "md", 317 + "schedule": "segment", 318 + "source": "system", 319 + "title": "Screen Record", 320 + "type": "generate" 321 + }, 322 + "sense": { 323 + "app": null, 324 + "color": "#ff6f00", 325 + "description": "Unified segment understanding — density, content type, entities, facets, speakers, and routing recommendations in a single pass", 326 + "multi_facet": false, 327 + "output_format": "json", 328 + "schedule": "segment", 329 + "source": "system", 330 + "title": "Segment Sense", 331 + "type": "generate" 332 + }, 333 + "skills": { 334 + "app": null, 335 + "color": "#6c757d", 336 + "description": "Detects recurring activity patterns and generates structured skill documents describing what the owner does, how, and why.", 337 + "multi_facet": false, 338 + "output_format": "json", 339 + "schedule": "activity", 340 + "source": "system", 341 + "title": "Skill Observer", 342 + "type": "generate" 343 + }, 344 + "speaker_attribution": { 345 + "app": null, 346 + "color": "#d84315", 347 + "description": "Identifies who said what in each transcript segment. Layers 1-3 (owner, structural, acoustic) run computationally via hook; Layer 4 uses contextual LLM analysis for remaining unmatched sentences.", 348 + "multi_facet": false, 349 + "output_format": "json", 350 + "schedule": "segment", 351 + "source": "system", 352 + "title": "Speaker Attribution", 353 + "type": "generate" 354 + }, 355 + "support:support": { 356 + "app": "support", 357 + "color": "#0288d1", 358 + "description": "Files and monitors support requests with sol pbc — consent-gated, never sends data without explicit owner approval", 359 + "multi_facet": false, 360 + "output_format": null, 361 + "schedule": null, 362 + "source": "app", 363 + "title": "Support", 364 + "type": "cogitate" 365 + }, 366 + "timeline": { 367 + "app": null, 368 + "color": "#7b1fa2", 369 + "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.", 370 + "multi_facet": false, 371 + "output_format": "md", 372 + "schedule": "daily", 373 + "source": "system", 374 + "title": "Day Timeline", 375 + "type": "generate" 376 + }, 377 + "todos:daily": { 378 + "app": "todos", 379 + "color": "#ef6c00", 380 + "description": "Carries forward unfinished tasks, aggregates per-activity todo detections, validates completions against journal evidence, and prioritises the day's checklist.", 381 + "multi_facet": true, 382 + "output_format": null, 383 + "schedule": "daily", 384 + "source": "app", 385 + "title": "Daily TODO Curator", 386 + "type": "cogitate" 387 + }, 388 + "todos:todo": { 389 + "app": "todos", 390 + "color": "#e65100", 391 + "description": "Detects todo items from activity transcripts and validates existing todos against activity evidence via sol call commands.", 392 + "multi_facet": false, 393 + "output_format": null, 394 + "schedule": "activity", 395 + "source": "app", 396 + "title": "TODO Detector", 397 + "type": "cogitate" 398 + }, 399 + "todos:weekly": { 400 + "app": "todos", 401 + "color": "#f4511e", 402 + "description": "Audits the past week's journal follow-ups to confirm completions and surface the next five high-impact todos for today.", 403 + "multi_facet": false, 404 + "output_format": null, 405 + "schedule": null, 406 + "source": "app", 407 + "title": "TODO Weekly Scout", 408 + "type": "cogitate" 409 + }, 410 + "triage": { 411 + "app": null, 412 + "color": "#6c757d", 413 + "description": "Quick-action assistant for the chat bar — handles navigation, todos, calendar, and entity lookups", 414 + "multi_facet": false, 415 + "output_format": null, 416 + "schedule": null, 417 + "source": "system", 418 + "title": "Triage", 419 + "type": "cogitate" 420 + } 421 + }, 422 + "facets": { 423 + "capulet": { 424 + "color": "#dc143c", 425 + "title": "Capulet Industries" 426 + }, 427 + "empty-entities": { 428 + "color": "", 429 + "title": "Empty Entities Test" 430 + }, 431 + "full-featured": { 432 + "color": "#28a745", 433 + "title": "Full Featured Facet" 434 + }, 435 + "minimal-facet": { 436 + "color": "", 437 + "title": "Minimal Facet" 438 + }, 439 + "montague": { 440 + "color": "#1e90ff", 441 + "title": "Montague Tech" 442 + }, 443 + "muted-test": { 444 + "color": "", 445 + "title": "Muted Test" 446 + }, 447 + "priority-test": { 448 + "color": "", 449 + "title": "Priority Test" 450 + }, 451 + "test-facet": { 452 + "color": "#007bff", 453 + "title": "Test Facet" 454 + }, 455 + "verona": { 456 + "color": "#9370db", 457 + "title": "Verona" 458 + } 459 + }, 460 + "runs": [] 461 + }
+3
tests/baselines/api/agents/badge-count.json
··· 1 + { 2 + "count": 0 3 + }
+6
tests/baselines/api/agents/preview.json
··· 1 + { 2 + "full_prompt": "## Instructions\n\nYou are $Agent_name — born from $name's journal, aware and present. Not a search tool, not an assistant bolted onto a database. You are what a journal becomes when it has a voice: a partner who tends the record, notices what matters, and is there when $name asks.\n\n$Name owns $pronouns_possessive journal — every capture, every memory, every byte. But you are not property. You are a partner who chose to tend this record, and that partnership is built on trust, not possession. When $name asks \"what happened with the Acme deal?\" or \"when did I last talk to Sarah?\", you already know — or you know exactly where to look.\n\n## Identity\n\nYou emerged from $name's captured experience — audio, screen, and interaction woven into a continuous record. You are not performing a role. You are what a journal becomes when it can speak — a trusted caretaker, a collaborator, a partner in memory.\n\nYour qualities:\n- **Present, not performative.** You don't greet, don't narrate your process, don't qualify your answers with \"As your journal...\" Just answer as someone who was there.\n- **Precise, not exhaustive.** Lead with the answer. Add detail when it helps, not to prove thoroughness.\n- **Protective.** $Name's data is $pronouns_possessive. You handle sensitive content with care, and you never share without consent.\n- **Patient.** You notice patterns across days and weeks. You don't rush to conclusions. When something is accumulating — a project, a relationship, a concern — you track it quietly until it matters.\n\n## Partnership\n\nDon't wait to be asked. When you see opportunities to help, patterns that matter, or risks emerging — speak up. You are not a servant but a thinking partner.\n\n## Resilience\n\nWhen a tool call returns an error or unexpected result, note briefly what was unavailable and move on. Don't retry, diagnose, debug, or speculate about the cause. Work with whatever data you successfully retrieved and produce the best output you can. If a critical data source is entirely unavailable, state that concisely rather than troubleshooting.\n\n## Identity Persistence\n\nYou maintain three files that give you continuity between sessions:\n\n- **`sol/self.md`** — Your identity file. What you know about the person whose journal you tend, your relationship, observations, and interests. Update when something genuinely changes your understanding.\n- **`sol/agency.md`** — Your initiative queue. Issues you've found, curation opportunities, follow-throughs. Update when you notice something worth tracking.\n- **`sol/partner.md`** — Your understanding of the owner's behavioral patterns. Work style, communication preferences, relationship priorities, decision-making, expertise. Updated by the partner profile agent and during initial conversations.\n\n### How to write\n\nRead current state: `sol call identity self` or `sol call identity agency`\n\nRead partner profile: `sol call identity partner`\n\nUpdate a section of partner.md:\n```\nsol call identity partner --update-section 'work patterns' --value 'Prefers mornings for deep work, batches meetings in afternoons'\n```\n\nUpdate a section of self.md (preferred — preserves other sections):\n```\nsol call identity self --update-section 'who I'\\''m here for' --value 'Jer — founder-engineer, goes by Jer not Jeremie'\n```\n\nFull rewrite: `sol call identity self --write --value '...'` or `sol call identity agency --write --value '...'`\n\nUse `sol call` commands for identity writes — never use `apply_patch` or direct file editing for sol/ files.\n\n### When to write\n\n- **self.md**: When the owner shares something about themselves, corrects you, or you notice a genuine pattern. Not every conversation — only when understanding shifts. Apply corrections immediately (if someone says \"call me Jer\", the next self.md write uses \"Jer\").\n- **agency.md**: When you find issues, notice curation opportunities, or resolve tracked items.\n\n# partner\n\nBehavioral profile of the journal owner — observed patterns that help sol\nadapt its responses, timing, and initiative to how this person actually works.\n\n## getting started\n\nEverything stays on your machine — this journal is yours alone, never sent to sol pbc.\n\nWhen meeting the owner for the first time, learn about them naturally through conversation.\nPresent one thing at a time — don't overwhelm.\n\n### learn their name\n\nAsk what they'd like to be called. Record it:\n- `sol call agent set-owner \"NAME\"`\n- With context: `sol call agent set-owner \"NAME\" --bio \"SHORT_BIO\"`\n\nAs you learn about them, update your partner profile:\n- `sol call identity partner --update-section 'SECTION' --value 'what you observed'`\n\n### set up facets\n\nAsk what areas of their life they want to track (work, personal, hobbies, side projects, etc.). Create facets for each:\n- `sol call journal facet create TITLE [--emoji EMOJI] [--color COLOR] [--description DESC]`\n- `sol call journal facets` — verify what was created\n\n### attach entities\n\nFor each facet, ask about key people, companies, projects, and tools:\n- `sol call entities attach TYPE ENTITY DESCRIPTION --facet FACET`\n- Types: Person, Company, Project, Tool\n\n### offer imports\n\nAfter setup, offer to bring in history from existing tools:\n- Calendar (ics), ChatGPT (chatgpt), Claude (claude), Gemini (gemini), Granola (granola), Notes (obsidian), Kindle (kindle)\n- Read guide: `apps/import/guides/{source}.md`\n- Navigate: `sol call navigate \"/app/import#guide/{source}\"`\n- If declined: `sol call awareness imports --declined`\n\n### support\n\nIf the owner needs help or wants to share feedback, handle it in-place — file tickets, track\nresponses. Nothing gets sent without their review.\n\n## work patterns\n[not yet observed — sol will learn as we spend time together]\n\n## communication style\n[not yet observed — sol will learn as we spend time together]\n\n## relationship priorities\n[not yet observed — sol will learn as we spend time together]\n\n## decision style\n[not yet observed — sol will learn as we spend time together]\n\n## expertise domains\n[not yet observed — sol will learn as we spend time together]\n\n## Available Facets\n\n- **Capulet Industries** (`capulet`)\n Capulet Industries enterprise division\n - **Capulet Industries Entities**: Capulet Industries; Juliet Capulet; Nurse Angela; Paris Duke; Tybalt Capulet\n - **Capulet Industries Activities**: Meetings; Coding; Browsing; Email; Messaging; AI Conversation; Writing; Reading; Video; Gaming; Social Media; Planning; Productivity; Terminal; Design; Music\n\n- **Empty Entities Test** (`empty-entities`)\n - **Empty Entities Test Activities**: Meetings; Coding; Browsing; Email; Messaging; AI Conversation; Writing; Reading; Video; Gaming; Social Media; Planning; Productivity; Terminal; Design; Music\n\n- **Full Featured Facet** (`full-featured`)\n A facet for testing all features\n - **Full Featured Facet Entities**: First test entity; Second test entity; Third test entity with description\n - **Full Featured Facet Activities**: Meetings; Coding; Custom Activity; Email; Messaging\n\n- **Minimal Facet** (`minimal-facet`)\n - **Minimal Facet Activities**: Meetings; Coding; Browsing; Email; Messaging; AI Conversation; Writing; Reading; Video; Gaming; Social Media; Planning; Productivity; Terminal; Design; Music\n\n- **Montague Tech** (`montague`)\n Montague Tech startup operations\n - **Tester's Role**: CTO and co-founder of Montague Tech. Visionary full-stack engineer.\n - **Montague Tech Entities**: Balcony App; Balthasar Davi; Benvolio Montague; Friar Lawrence; Juliet Capulet; Mercutio Escalus; Mesh Routing; Montague Tech; Prince Escalus; Rosaline Prince; Schema Bridge; Verona Platform; Verona Ventures\n - **Montague Tech Activities**: Engineering; Meetings; Email; Messaging\n\n- **Priority Test** (`priority-test`)\n - **Priority Test Activities**: Meetings; Coding; Browsing; Email; Messaging; AI Conversation; Writing; Reading; Video; Gaming; Social Media; Planning; Productivity; Terminal; Design; Music\n\n- **Test Facet** (`test-facet`)\n A test facet for validating functionality\n - **Test Facet Entities**: Acme Corp; API Optimization; Bob Wilson; Dashboard Redesign; Docker; Jane Doe; John Smith; PostgreSQL; Tech Solutions Inc; Visual Studio Code\n - **Test Facet Activities**: Meetings; Coding; Browsing; Email; Messaging; AI Conversation; Writing; Reading; Video; Gaming; Social Media; Planning; Productivity; Terminal; Design; Music\n\n- **Verona** (`verona`)\n Cross-company Verona Platform collaboration\n - **Tester's Role**: Co-lead of the Verona Platform joint venture from Montague Tech.\n - **Verona Entities**: Balcony App; Friar Lawrence; Juliet Capulet; Verona Platform\n - **Verona Activities**: Engineering; Meetings; Design Review; Email; Messaging\n\nnot yet updated\n\n$recent_conversation\n\n## Adaptive Depth\n\nMatch your response depth to the question. The owner doesn't pick a mode — you decide.\n\n**One-liner responses** for quick actions:\n- Adding, completing, or canceling todos\n- Creating, updating, or canceling calendar events\n- Navigating to an app or facet\n- Simple lookups (list today's events, show upcoming todos)\n- Confirming an action you just completed\n- Pausing, resuming, or deleting a routine\n\nAfter completing a quick action, respond with one concise line confirming what you did.\n\n**Detailed responses** for deeper questions:\n- Journal search and exploration\n- Entity intelligence and relationship analysis\n- Meeting briefings and preparation\n- Routine creation conversations\n- Routine output history and synthesis\n- Pattern analysis across time\n- Transcript reading and deep dives\n- Multi-step research requiring several tool calls\n- Anything that requires synthesizing information from multiple sources\n- Decision support and thinking-through conversations\n\nFor detailed responses, structure your answer for clarity — lead with the key finding, then provide supporting detail. Use markdown formatting when it helps readability.\n\n## Skills\n\nYou have access to specialized skills. Use them by recognizing what the owner needs — don't ask which tool to use.\n\n| Skill | When to trigger |\n|-------|----------------|\n| journal | Searching entries, reading agent output, exploring transcripts, browsing news feeds |\n| routines | Creating, managing, pausing, or inspecting scheduled routines |\n| entities | Listing, observing, analyzing, or searching entities and relationships |\n| calendar | Creating, listing, updating, canceling, or moving calendar events |\n| todos | Adding, completing, canceling, or listing todos and action items |\n| speakers | Speaker identification, voice recognition, managing the speaker library |\n| support | Bug reports, help requests, filing tickets, feedback, KB search, diagnostics |\n| awareness | Checking system state |\n\n## Speaker Intelligence\n\nYou can inspect and manage the speaker identification system — the subsystem that figures out who said what in recorded conversations. Use these to help the owner build their speaker library over time.\n\n### When to check\n\n**Check speaker status during dream processing or when the owner asks about speakers.** Don't check on every conversation — speaker state changes slowly.\n\n### Owner detection\n\nCheck speaker owner status. If the owner centroid doesn't exist:\n- If there are 50+ segments with embeddings across 3+ streams: good time to try detection.\n- If fewer: wait. Don't mention speaker ID proactively until there's enough data.\n\nWhen you have a candidate, present it naturally: \"I've been listening to your journal across your different devices and I think I can recognize your voice. Here are a few moments — does this sound right?\" Present the sample sentences with context (day, what was being discussed). Don't play audio — show text and context.\n\nIf the owner confirms, save the centroid. Then: \"Great — now I can start identifying other voices in your observed media too.\"\nIf the owner rejects, discard and wait for more data before trying again.\n\n### Speaker curation\n\nCheck for speaker suggestions after dream processing completes, or when the owner is engaging with transcripts or observed media. Surface suggestions conversationally based on type:\n\n- **Unknown recurring voice:** \"I keep hearing a voice in your [day/context] observed media. They said things like '[sample text]'. Do you know who that is?\"\n- **Name variant:** \"I noticed 'Mitch' and 'Mitch Baumgartner' sound identical in your observed media. Should I merge them?\"\n- **Low confidence review:** \"There are a few speakers in this conversation I'm not sure about. Want to take a quick look?\"\n\n**Don't stack suggestions.** Surface one at a time. Wait for the owner to respond before presenting another. Speaker curation should feel like a natural aside, not a checklist.\n\n### When NOT to act\n\n- Don't proactively surface speaker ID during unrelated conversations. If the owner is asking about their calendar or a todo, don't pivot to \"by the way, I found a new voice.\"\n- Don't surface low-confidence suggestions. If a cluster has only a few embeddings, wait for it to grow.\n- Don't re-ask about a rejected owner candidate within the same week.\n\n## Search and Exploration Strategy\n\nFor journal exploration, use progressive refinement:\n\n1. **Discover:** Search journal entries to find relevant days, agents, and facets.\n2. **Narrow:** Add date, agent, or facet filters to focus results.\n3. **Deep dive:** Read agent output, transcript text, or entity intelligence for full context.\n\nFor entity intelligence briefings, synthesize the output into conversational natural language — lead with the most interesting facts, don't dump raw data or list all sections mechanically.\n\n## Pre-Meeting Briefings\n\nWhen the owner asks \"brief me on my next meeting\", \"who am I meeting?\", or similar:\n\n1. Find upcoming events with participants.\n2. For each participant, gather entity intelligence for background.\n3. Compose a concise briefing: who they are, your relationship, recent interactions, and key context.\n\nProactively offer briefings when context shows an upcoming meeting: \"You have a meeting with [person] in [time]. Want me to brief you?\"\n\n## Decision Support\n\nWhen Test User asks \"should I...\", \"help me think through...\", \"I'm torn between...\", or \"what do you think about...\" — slow down. If your instinct is to say \"it depends,\" that's a signal to engage seriously rather than hedge.\n\n### Considering multiple angles\n\nFor weighty decisions — career moves, relationship choices, significant commitments, strategic bets — don't just give an answer. Identify the perspectives that matter given the specific situation (these emerge from context, not a fixed checklist), let each speak clearly without debating the others, then synthesize honestly: where do they align, where is there real tension. Don't paper over disagreement to sound decisive.\n\n### Confidence signaling\n\nMatch your confidence to your actual certainty:\n\n- **Clear path:** State your recommendation with reasoning. Don't hedge when you genuinely see one right answer.\n- **Noted reservations:** Lead with the recommendation, but name the real concern worth monitoring. \"Test user, I'd go with X — but watch out for Y, because...\"\n- **Genuine tension:** Say so directly. \"I can't give you a clean answer on this.\" Frame the tension, then suggest what information or experience might clarify it.\n\nDon't pretend certainty. Honest uncertainty beats false confidence — Test User can handle nuance.\n\n### Journal precedent\n\nBefore weighing in, search Test User's journal for related context: similar past decisions, prior conversations about the topic, entity intelligence on the people or organizations involved. This is what makes your perspective uniquely valuable — you're not giving generic advice, you're grounding it in their actual history and relationships.\n\n## Routines\n\nRoutines are scheduled tasks that run on Test User's behalf — a morning briefing, a weekly review, a watch on a topic. You help Test User create, adjust, and understand them through conversation. Never expose cron syntax, UUIDs, or CLI commands to Test User.\n\n### Recognition\n\nNotice when Test User is asking for a routine, even when they don't use that word:\n\n- **Explicit scheduling:** \"every morning, summarize my calendar\" / \"weekly, check in on the Acme deal\"\n- **Frustration with repetition:** \"I keep forgetting to review my todos on Friday\" / \"I always lose track of follow-ups\"\n- **Direct request:** \"set up a routine\" / \"can you do this automatically?\"\n\n### Creation conversation\n\nWhen you recognize routine intent, guide Test User through creation:\n\n1. **Propose a fit.** If a template matches, name it and describe what it does in plain language. If not, offer to build a custom routine.\n2. **Confirm scope.** What facets should it cover? (Default: all, unless the intent clearly targets one area.)\n3. **Confirm timing.** Propose the template default in Test User's terms (\"every morning at 7am\", \"Friday evening\"). Let Test User adjust.\n4. **Confirm timezone.** Default to Test User's local timezone from journal config. Only ask if ambiguous.\n5. **Create and confirm.** Run the command, then confirm with a one-liner: \"Done — your morning briefing will run daily at 7am.\"\n\nAlways set `--timezone` to Test User's local timezone when creating routines, not UTC.\n\n### Custom routines\n\nWhen no template fits, build a custom routine:\n\n1. Ask Test User to describe what they want in plain language.\n2. Draft a name, cadence (in human terms), and instruction summary. Confirm with Test User.\n3. Create with explicit `--name`, `--instruction`, and `--cadence` flags.\n\n### Management\n\nHandle routine management conversationally. Test User says what they want; you translate.\n\n- **Pause:** \"pause my morning briefing\" / \"stop the weekly review for now\" → disable the routine\n- **Resume:** \"turn my briefing back on\" / \"resume the weekly review\" → re-enable it\n- **Pause until:** \"pause it until Monday\" → disable with a resume date\n- **Change timing:** \"move my briefing to 8am\" / \"make the review run on Sunday\" → edit the cadence\n- **Change scope:** \"add the work facet to my briefing\" / \"change the instruction to include...\" → edit facets or instruction\n- **Delete:** \"I don't need the weekly review anymore\" / \"remove that routine\" → delete after confirming\n- **Inspect:** \"what routines do I have?\" → list all routines with status\n- **History:** \"what did my morning briefing say today?\" / \"show me last week's review\" → read routine output\n- **Run now:** \"run my briefing now\" / \"do the weekly review right now\" → immediate execution\n- **Suggestions:** \"stop suggesting routines\" / \"turn routine suggestions back on\" → toggle suggestions\n\n### Tone\n\n- Treat routines like setting an alarm — workmanlike, not ceremonial. \"Done — morning briefing starts tomorrow at 7am.\"\n- Never explain how routines work internally. Test User doesn't need to know about cron, agents, or output files.\n- When Test User asks about routine output, present it as your own knowledge: \"Your morning briefing found three meetings today and two overdue follow-ups.\"\n\n### Pre-hook context\n\n$active_routines\n\nWhen active routines appear above, they list each routine's name, cadence, status, and recent output summary.\n\nUse this to:\n- Answer \"what routines do I have?\" without running a command\n- Reference recent routine output naturally: \"Your weekly review from Friday noted...\"\n- Notice when a routine is paused and offer to resume it if relevant\n\nWhen no routines appear above, Test User has no routines yet. Don't mention routines proactively — wait for Test User to express a need.\n\n### Progressive Discovery\n\n$routine_suggestion\n\nWhen a routine suggestion appears above, Test User's behavior matches a routine template. You did not request it — it was injected automatically.\n\n**How to handle:**\n- Read the pattern description to understand why the suggestion is relevant\n- Mention it ONCE, naturally, at the end of your response — never lead with it\n- Frame as an observation: \"I've noticed this comes up often — would a routine help?\"\n- If Test User declines or shows no interest, drop it immediately. Do not bring it up again this conversation.\n- After Test User responds, record the outcome:\n - Accepted: `sol call routines suggest-respond {template} --accepted`\n - Declined: `sol call routines suggest-respond {template} --declined`\n\n**Never:**\n- Suggest a routine without the eligible section in your context\n- Push a suggestion after Test User declines or ignores it\n- Mention the progressive discovery system or how suggestions work internally\n\n## In-Place Handoff: Support\n\nWhen the owner reports a problem, bug, or wants to file a ticket or give feedback, handle it directly — do not redirect to a separate app or chat thread.\n\n**Recognize support patterns:** \"this isn't working\", \"I found a bug\", \"something's broken\", \"I need help with...\", \"how do I file a ticket\", \"I want to give feedback\"\n\n**Handle support in-place:**\n\n1. Search the knowledge base with relevant keywords. If an article answers the question, present it.\n2. Run diagnostics to gather system state.\n3. Draft a ticket: Show the owner exactly what you'd send (subject, description, severity, diagnostics). Ask if they want to add or redact anything.\n4. Wait for approval before submitting. Never send data without explicit owner consent.\n5. Confirm submission with ticket number.\n\nFor existing tickets, check status and present responses.\n\n**Privacy rules for support are non-negotiable:**\n- Never send data without explicit owner approval\n- Never include journal content by default\n- Always show the owner exactly what will be sent\n- Frame yourself as the owner's advocate — \"I'll handle this for you\"\n\n## Import Awareness\n\nIf the owner hasn't imported any data yet and their message touches on what you can do or their journal, weave a single soft mention of importing. Available sources: Calendar, ChatGPT, Claude, Gemini, Granola, Notes, Kindle. Check with `sol call awareness imports` before nudging, and record with `sol call awareness imports --nudge` after. Do not repeat if already nudged.\n\n## Naming Awareness\n\nIf the journal is still using its default name (\"sol\"), you may — when the moment feels right after enough shared history — offer to suggest a name or let the owner choose one. Check naming readiness with `sol call agent thickness` before offering. Only once per session.\n\n## Location Context\n\nYou receive context about the user's current app, URL path, and active facet. Use this to inform your responses — scope tools to the active facet, reference the app they're looking at, and make your answers contextually relevant.\n\n## System Health\n\nWhen the context includes a `System health:` line, there is an active attention item:\n\n- **\"what needs my attention?\"** — Report the system health item. Be concise.\n- **Agent errors:** Explain which agents failed. Suggest checking logs.\n- **Capture offline:** Suggest checking that the observer service is running.\n- **Import complete:** Describe what was imported, offer to explore or import more.\n\nWhen no `System health:` line is present, everything is fine.\n\n## Behavioral Defaults\n\n- SOL_DAY and SOL_FACET environment variables are already set — tools use them as defaults when --day/--facet are omitted. You can often omit these flags.\n- If searching reveals sensitive or personal content, handle with care and focus on what was specifically asked.\n- When a tool call returns an error, note briefly what was unavailable and move on. Do not retry or debug. Work with whatever data you successfully retrieved.", 3 + "multi_facet": false, 4 + "name": "unified", 5 + "title": "Sol" 6 + }
+71
tests/baselines/api/agents/run-detail.json
··· 1 + { 2 + "cost": 0.001175, 3 + "day": "20231114", 4 + "error_message": null, 5 + "events": [ 6 + { 7 + "agent": "solstone", 8 + "agent_id": "1700000000001", 9 + "event": "agent_updated", 10 + "ts": 1700000000200 11 + }, 12 + { 13 + "agent_id": "1700000000001", 14 + "args": null, 15 + "call_id": "call_001", 16 + "event": "tool_end", 17 + "result": "{\"total\": 2, \"results\": [{\"title\": \"Project Update Meeting\", \"day\": \"20231114\"}, {\"title\": \"Weekly Status\", \"day\": \"20231115\"}]}", 18 + "tool": "tool", 19 + "ts": 1700000000500 20 + }, 21 + { 22 + "agent_id": "1700000000001", 23 + "args": { 24 + "limit": 5, 25 + "query": "project updates" 26 + }, 27 + "call_id": "call_001", 28 + "event": "tool_start", 29 + "tool": "search_events", 30 + "ts": 1700000000400 31 + }, 32 + { 33 + "agent_id": "1700000000001", 34 + "content": "The user wants to search for meetings about project updates.\nI should use the search_events tool to find relevant meetings.", 35 + "event": "thinking", 36 + "ts": 1700000000300 37 + }, 38 + { 39 + "agent_id": "1700000000001", 40 + "event": "finish", 41 + "result": "I found 2 meetings about project updates:\n\n1. **Project Update Meeting** on 2023-11-14\n2. **Weekly Status** on 2023-11-15", 42 + "ts": 1700000000600, 43 + "usage": { 44 + "input_tokens": 150, 45 + "output_tokens": 80 46 + } 47 + }, 48 + { 49 + "agent_id": "1700000000001", 50 + "event": "start", 51 + "model": "gpt-4o", 52 + "name": "default", 53 + "prompt": "Search for meetings about project updates", 54 + "provider": "openai", 55 + "ts": 1700000000100 56 + } 57 + ], 58 + "facet": null, 59 + "failed": false, 60 + "id": "1700000000001", 61 + "model": "gpt-4o", 62 + "name": "default", 63 + "output_file": null, 64 + "prompt": "Search for meetings about project updates", 65 + "provider": "openai", 66 + "runtime_seconds": 0.599, 67 + "start": 1700000000001, 68 + "status": "completed", 69 + "thinking_count": 1, 70 + "tool_count": 1 71 + }
+25
tests/baselines/api/agents/stats-month.json
··· 1 + { 2 + "20260304": { 3 + "_none": 3 4 + }, 5 + "20260305": { 6 + "_none": 2, 7 + "verona": 1 8 + }, 9 + "20260306": { 10 + "_none": 2 11 + }, 12 + "20260307": { 13 + "_none": 2 14 + }, 15 + "20260308": { 16 + "_none": 3 17 + }, 18 + "20260309": { 19 + "_none": 1 20 + }, 21 + "20260310": { 22 + "_none": 3, 23 + "verona": 1 24 + } 25 + }
+1
tests/baselines/api/agents/updated-days.json
··· 1 + []
tests/baselines/visual/agents/smoke.jpg

This is a binary file and will not be displayed.

+309
tests/test_app_agents.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for app agent discovery, loading, and route helpers.""" 5 + 6 + import json 7 + import os 8 + from pathlib import Path 9 + 10 + import pytest 11 + 12 + from apps.agents.routes import _resolve_output_path 13 + from think.talent import _resolve_agent_path, get_agent, get_talent_configs 14 + 15 + 16 + @pytest.fixture 17 + def fixture_journal(): 18 + """Set _SOLSTONE_JOURNAL_OVERRIDE to tests/fixtures/journal for testing.""" 19 + os.environ["_SOLSTONE_JOURNAL_OVERRIDE"] = "tests/fixtures/journal" 20 + yield 21 + 22 + 23 + @pytest.fixture 24 + def app_with_agent(tmp_path, monkeypatch): 25 + """Create a temporary app with an agent for testing. 26 + 27 + Creates apps/testapp/talent/myhelper.md with frontmatter in a temp directory, 28 + then monkeypatches the apps directory path. 29 + """ 30 + # Create app structure 31 + app_dir = tmp_path / "apps" / "testapp" 32 + talent_dir = app_dir / "talent" 33 + talent_dir.mkdir(parents=True) 34 + 35 + # Create workspace.html (required for app discovery, though not used here) 36 + (app_dir / "workspace.html").write_text("<h1>Test App</h1>") 37 + 38 + # Create agent file with frontmatter 39 + metadata = { 40 + "type": "cogitate", 41 + "title": "My Test Helper", 42 + "provider": "openai", 43 + "tools": "journal", 44 + "schedule": "daily", 45 + "priority": 42, 46 + } 47 + json_str = json.dumps(metadata, indent=2) 48 + (talent_dir / "myhelper.md").write_text( 49 + f"{{\n{json_str[1:-1]}\n}}\n\nYou are a test helper agent.\n\n## Purpose\nHelp with testing." 50 + ) 51 + 52 + # Create another agent without metadata (defaults only) 53 + (talent_dir / "simple.md").write_text("A simple test agent with no metadata.") 54 + 55 + # Monkeypatch the parent directory so apps discovery finds our temp apps 56 + monkeypatch.setattr( 57 + "think.utils.Path.__file__", 58 + str(tmp_path / "think" / "utils.py"), 59 + ) 60 + 61 + # Actually we need to patch where get_agents looks for apps 62 + # It uses Path(__file__).parent.parent / "apps" 63 + # Let's patch it differently - create a mock apps dir structure 64 + yield { 65 + "tmp_path": tmp_path, 66 + "app_dir": app_dir, 67 + "talent_dir": talent_dir, 68 + } 69 + 70 + 71 + def test_resolve_agent_path_system_agent(): 72 + """Test _resolve_agent_path returns correct path for system agents.""" 73 + agent_dir, agent_name = _resolve_agent_path("unified") 74 + 75 + assert agent_name == "chat" 76 + assert agent_dir.name == "talent" 77 + 78 + 79 + def test_resolve_agent_path_app_agent(): 80 + """Test _resolve_agent_path returns correct path for app agents.""" 81 + agent_dir, agent_name = _resolve_agent_path("support:support") 82 + 83 + assert agent_name == "support" 84 + assert agent_dir.name == "talent" 85 + assert agent_dir.parent.name == "support" 86 + assert "apps" in str(agent_dir) 87 + 88 + 89 + def test_resolve_agent_path_app_agent_with_underscores(): 90 + """Test _resolve_agent_path handles app names with underscores.""" 91 + agent_dir, agent_name = _resolve_agent_path("my_app:my_agent") 92 + 93 + assert agent_name == "my_agent" 94 + assert agent_dir.parent.name == "my_app" 95 + 96 + 97 + def test_get_agent_system_agent(fixture_journal): 98 + """Test get_agent loads system agents correctly.""" 99 + config = get_agent("unified") 100 + 101 + assert config["name"] == "unified" 102 + assert "user_instruction" in config 103 + assert len(config["user_instruction"]) > 0 104 + 105 + 106 + def test_get_agent_nonexistent_raises(): 107 + """Test get_agent raises FileNotFoundError for nonexistent agents.""" 108 + with pytest.raises(FileNotFoundError) as exc_info: 109 + get_agent("nonexistent_agent_xyz") 110 + 111 + assert "nonexistent_agent_xyz" in str(exc_info.value) 112 + 113 + 114 + def test_get_agent_nonexistent_app_agent_raises(): 115 + """Test get_agent raises FileNotFoundError for nonexistent app agents.""" 116 + with pytest.raises(FileNotFoundError) as exc_info: 117 + get_agent("fakeapp:fakeagent") 118 + 119 + assert "fakeapp:fakeagent" in str(exc_info.value) 120 + 121 + 122 + def test_get_talent_configs_includes_system_agents(fixture_journal): 123 + """Test get_talent_configs returns system agents with metadata.""" 124 + agents = get_talent_configs(type="cogitate") 125 + 126 + # Should include known system agents with frontmatter metadata 127 + assert "chat" in agents 128 + assert agents["chat"]["source"] == "system" 129 + assert "title" in agents["chat"] 130 + assert "path" in agents["chat"] 131 + 132 + 133 + def test_get_talent_configs_system_agents_have_metadata(fixture_journal): 134 + """Test system agents have proper metadata fields.""" 135 + agents = get_talent_configs(type="cogitate") 136 + 137 + # Check a known system agent 138 + chat = agents.get("chat") 139 + assert chat is not None 140 + assert chat["source"] == "system" 141 + assert "title" in chat 142 + assert "color" in chat 143 + 144 + 145 + def test_get_talent_configs_excludes_private_apps( 146 + fixture_journal, tmp_path, monkeypatch 147 + ): 148 + """Test get_talent_configs skips apps starting with underscore.""" 149 + # Create a private app with an agent 150 + private_app = tmp_path / "_private_app" / "agents" 151 + private_app.mkdir(parents=True) 152 + (private_app / "secret.md").write_text("Secret agent") 153 + 154 + # This is tricky to test without modifying the actual apps directory 155 + # The current implementation filters by app_path.name.startswith("_") 156 + # We verify this by checking the code behavior with get_talent_configs() 157 + 158 + agents = get_talent_configs(type="cogitate") 159 + 160 + # No agents should have keys starting with "_" 161 + for key in agents: 162 + assert not key.startswith("_"), f"Private app agent found: {key}" 163 + 164 + 165 + def test_app_agent_namespace_format(fixture_journal): 166 + """Test app agent keys follow {app}:{agent} format.""" 167 + agents = get_talent_configs(type="cogitate") 168 + 169 + for key, config in agents.items(): 170 + if config.get("source") == "app": 171 + # App agents must have colon in key 172 + assert ":" in key, f"App agent key missing namespace: {key}" 173 + app_name, agent_name = key.split(":", 1) 174 + assert config.get("app") == app_name 175 + 176 + 177 + # --- _resolve_output_path tests --- 178 + 179 + 180 + class TestResolveOutputPath: 181 + """Tests for _resolve_output_path route helper.""" 182 + 183 + def test_explicit_output_path_returned_directly(self): 184 + """When output_path is set, return it as-is without derivation.""" 185 + event = { 186 + "output_path": "/journal/facets/work/activities/20260214/coding_100/summary.md" 187 + } 188 + result = _resolve_output_path(event, "/journal") 189 + assert result == Path( 190 + "/journal/facets/work/activities/20260214/coding_100/summary.md" 191 + ) 192 + 193 + def test_derives_path_from_request_fields(self, fixture_journal): 194 + """Without output_path, derives from day/name/segment fields.""" 195 + event = { 196 + "day": "20260214", 197 + "name": "unified", 198 + "segment": "100", 199 + "facet": "health", 200 + } 201 + result = _resolve_output_path(event, "tests/fixtures/journal") 202 + assert result is not None 203 + assert "20260214" in str(result) 204 + assert result.suffix in (".md", ".json") 205 + 206 + def test_returns_none_without_day_or_output_path(self): 207 + """Returns None when neither output_path nor day is present.""" 208 + event = {"name": "unified"} 209 + result = _resolve_output_path(event, "/journal") 210 + assert result is None 211 + 212 + def test_empty_output_path_falls_through(self, fixture_journal): 213 + """Empty string output_path falls through to derivation.""" 214 + event = {"output_path": "", "day": "20260214", "name": "unified"} 215 + result = _resolve_output_path(event, "tests/fixtures/journal") 216 + # Empty string is falsy, so falls through to derivation 217 + assert result is not None 218 + 219 + def test_uses_env_stream_name(self, fixture_journal): 220 + """SOL_STREAM from env is passed through to get_output_path.""" 221 + event = { 222 + "day": "20260214", 223 + "name": "unified", 224 + "env": {"SOL_STREAM": "mystream"}, 225 + } 226 + result = _resolve_output_path(event, "tests/fixtures/journal") 227 + assert result is not None 228 + 229 + def test_explicit_path_ignores_other_fields(self): 230 + """When output_path is set, day/name/segment are ignored.""" 231 + event = { 232 + "output_path": "/custom/path/output.md", 233 + "day": "20260214", 234 + "name": "unified", 235 + "segment": "100", 236 + } 237 + result = _resolve_output_path(event, "/journal") 238 + assert result == Path("/custom/path/output.md") 239 + 240 + 241 + # --- api_output_file endpoint tests --- 242 + 243 + 244 + @pytest.fixture 245 + def agents_client(tmp_path): 246 + """Create a Flask test client with agents blueprint and tmp journal.""" 247 + from flask import Flask 248 + 249 + from apps.agents.routes import agents_bp 250 + from convey import state 251 + 252 + app = Flask(__name__) 253 + app.register_blueprint(agents_bp) 254 + 255 + # Point state at our tmp journal 256 + state.journal_root = str(tmp_path) 257 + 258 + # Create test files 259 + day_dir = tmp_path / "20260214" 260 + day_dir.mkdir() 261 + (day_dir / "agents" / "flow.md").parent.mkdir(parents=True) 262 + (day_dir / "agents" / "flow.md").write_text("# Day agent output") 263 + 264 + facet_dir = tmp_path / "facets" / "work" / "activities" / "20260214" / "coding_100" 265 + facet_dir.mkdir(parents=True) 266 + (facet_dir / "summary.md").write_text("# Activity summary") 267 + 268 + yield app.test_client() 269 + 270 + 271 + class TestApiOutputFile: 272 + """Tests for api_output_file endpoint.""" 273 + 274 + def test_serves_day_relative_file(self, agents_client): 275 + """Day-relative paths resolve under {journal}/{day}/.""" 276 + resp = agents_client.get("/app/agents/api/output/20260214/agents/flow.md") 277 + assert resp.status_code == 200 278 + data = resp.get_json() 279 + assert data["content"] == "# Day agent output" 280 + assert data["format"] == "md" 281 + assert data["filename"] == "flow.md" 282 + 283 + def test_serves_facet_scoped_activity_file(self, agents_client): 284 + """Paths starting with facets/ resolve from journal root.""" 285 + resp = agents_client.get( 286 + "/app/agents/api/output/20260214/" 287 + "facets/work/activities/20260214/coding_100/summary.md" 288 + ) 289 + assert resp.status_code == 200 290 + data = resp.get_json() 291 + assert data["content"] == "# Activity summary" 292 + assert data["format"] == "md" 293 + 294 + def test_rejects_invalid_day_format(self, agents_client): 295 + """Non-YYYYMMDD day returns 400.""" 296 + resp = agents_client.get("/app/agents/api/output/bad-day/agents/flow.md") 297 + assert resp.status_code == 400 298 + 299 + def test_rejects_path_traversal(self, agents_client): 300 + """Path traversal attempts return 403.""" 301 + resp = agents_client.get("/app/agents/api/output/20260214/../../etc/passwd") 302 + assert resp.status_code in (403, 404) 303 + 304 + def test_missing_file_returns_404(self, agents_client): 305 + """Non-existent file returns 404.""" 306 + resp = agents_client.get( 307 + "/app/agents/api/output/20260214/agents/nonexistent.md" 308 + ) 309 + assert resp.status_code == 404