personal memory agent
at main 202 lines 6.6 kB view raw
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4"""CLI commands for transcript browsing. 5 6Provides human-friendly CLI access to transcript operations, paralleling the 7transcript helper functions in ``think.cluster`` but optimized for terminal use. 8 9Auto-discovered by ``think.call`` and mounted as ``sol call transcripts ...``. 10""" 11 12import typer 13 14from think.cluster import ( 15 cluster, 16 cluster_period, 17 cluster_range, 18 cluster_scan, 19 cluster_segments, 20 cluster_span, 21) 22from think.utils import ( 23 day_dirs, 24 get_sol_stream, 25 resolve_sol_day, 26 resolve_sol_segment, 27 truncated_echo, 28) 29 30app = typer.Typer(help="Transcript browsing.") 31 32 33@app.command("scan") 34def scan( 35 day: str | None = typer.Argument( 36 default=None, help="Day YYYYMMDD (default: SOL_DAY env)." 37 ), 38) -> None: 39 """List transcript coverage ranges for a day.""" 40 day = resolve_sol_day(day) 41 transcript_ranges, screen_ranges = cluster_scan(day) 42 43 typer.echo("Transcripts:") 44 if transcript_ranges: 45 for start, end in transcript_ranges: 46 typer.echo(f" {start} - {end}") 47 else: 48 typer.echo(" (none)") 49 50 typer.echo("Percepts:") 51 if screen_ranges: 52 for start, end in screen_ranges: 53 typer.echo(f" {start} - {end}") 54 else: 55 typer.echo(" (none)") 56 57 58@app.command("segments") 59def segments( 60 day: str | None = typer.Argument( 61 default=None, help="Day YYYYMMDD (default: SOL_DAY env)." 62 ), 63) -> None: 64 """List recording segments for a day.""" 65 day = resolve_sol_day(day) 66 segment_list = cluster_segments(day) 67 if not segment_list: 68 typer.echo("No segments.") 69 return 70 71 for segment in segment_list: 72 key = segment.get("key", "") 73 start = segment.get("start", "") 74 end = segment.get("end", "") 75 types = ", ".join(segment.get("types", [])) 76 typer.echo(f"{key} {start} - {end} [{types}]") 77 78 79@app.command("read") 80def read( 81 day: str | None = typer.Argument( 82 default=None, help="Day YYYYMMDD (default: SOL_DAY env)." 83 ), 84 start: str | None = typer.Option(None, "--start", help="Start time (HHMMSS)."), 85 length: int | None = typer.Option(None, "--length", help="Length in minutes."), 86 segment: str | None = typer.Option( 87 None, "--segment", help="Segment key (HHMMSS_LEN, default: SOL_SEGMENT env)." 88 ), 89 segments: str | None = typer.Option( 90 None, "--segments", help="Comma-separated segment keys for a span." 91 ), 92 stream: str | None = typer.Option( 93 None, "--stream", help="Stream name (default: SOL_STREAM env)." 94 ), 95 full: bool = typer.Option( 96 False, "--full", help="Include transcripts, screen, and agents." 97 ), 98 raw: bool = typer.Option( 99 False, "--raw", help="Include transcripts and screen only." 100 ), 101 transcripts: bool = typer.Option( 102 False, "--transcripts", help="Include transcript content." 103 ), 104 audio: bool = typer.Option( 105 False, "--audio", help="Alias for --transcripts.", hidden=True 106 ), 107 percepts: bool = typer.Option(False, "--percepts", help="Include screen percepts."), 108 screen: bool = typer.Option( 109 False, "--screen", help="Alias for --percepts.", hidden=True 110 ), 111 agents: bool = typer.Option(False, "--agents", help="Include agent outputs."), 112 max_bytes: int = typer.Option( 113 16384, "--max", help="Max output bytes (0 = unlimited)." 114 ), 115) -> None: 116 """Read transcript content for a day, segment, or time range.""" 117 day = resolve_sol_day(day) 118 segment = resolve_sol_segment(segment) 119 stream = stream or get_sol_stream() 120 # --audio is an alias for --transcripts, --screen is an alias for --percepts 121 transcripts = transcripts or audio 122 percepts = percepts or screen 123 124 if full and raw: 125 typer.echo("Error: Cannot use --full and --raw together.", err=True) 126 raise typer.Exit(1) 127 128 if (full or raw) and (transcripts or percepts or agents): 129 typer.echo( 130 "Error: Cannot mix --full/--raw with individual source flags.", err=True 131 ) 132 raise typer.Exit(1) 133 134 if full: 135 sources: dict[str, bool] = { 136 "transcripts": True, 137 "percepts": True, 138 "agents": True, 139 } 140 elif raw: 141 sources = {"transcripts": True, "percepts": True, "agents": False} 142 elif transcripts or percepts or agents: 143 sources = {"transcripts": transcripts, "percepts": percepts, "agents": agents} 144 else: 145 sources = {"transcripts": True, "percepts": False, "agents": True} 146 147 # Validate mutually exclusive selection modes 148 mode_count = sum( 149 [ 150 segment is not None, 151 segments is not None, 152 start is not None or length is not None, 153 ] 154 ) 155 if mode_count > 1: 156 typer.echo( 157 "Error: Cannot mix --segment, --segments, and --start/--length.", 158 err=True, 159 ) 160 raise typer.Exit(1) 161 162 if (start is not None) != (length is not None): 163 typer.echo("Error: --start and --length must be used together.", err=True) 164 raise typer.Exit(1) 165 166 if start is not None and length is not None: 167 from datetime import datetime, timedelta 168 169 start_dt = datetime.strptime(start, "%H%M%S") 170 end_dt = start_dt + timedelta(minutes=length) 171 markdown = cluster_range(day, start, end_dt.strftime("%H%M%S"), sources) 172 elif segments is not None: 173 span = [s.strip() for s in segments.split(",") if s.strip()] 174 markdown, _counts = cluster_span(day, span, sources, stream=stream) 175 elif segment is not None: 176 markdown, _counts = cluster_period(day, segment, sources, stream=stream) 177 else: 178 markdown, _counts = cluster(day, sources) 179 180 truncated_echo(markdown, max_bytes) 181 182 183@app.command("stats") 184def stats(month: str = typer.Argument(help="Month (YYYYMM).")) -> None: 185 """Show daily transcript coverage counts for a month.""" 186 days = sorted(day for day in day_dirs().keys() if day.startswith(month)) 187 188 days_with_data = 0 189 for day in days: 190 transcript_ranges, screen_ranges = cluster_scan(day) 191 if transcript_ranges or screen_ranges: 192 days_with_data += 1 193 typer.echo( 194 f"{day} transcripts:{len(transcript_ranges)} percepts:{len(screen_ranges)}" 195 ) 196 197 if not days_with_data: 198 typer.echo(f"No data for {month}.") 199 return 200 201 typer.echo("") 202 typer.echo(f"Total: {days_with_data} days with data")