personal memory agent
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")