diff --git a/apps/transcripts/__init__.py b/apps/transcripts/__init__.py new file mode 100644 index 000000000..f2c5b4f39 --- /dev/null +++ b/apps/transcripts/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc diff --git a/apps/transcripts/call.py b/apps/transcripts/call.py new file mode 100644 index 000000000..d4fdfe648 --- /dev/null +++ b/apps/transcripts/call.py @@ -0,0 +1,137 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""CLI commands for transcript browsing. + +Provides human-friendly CLI access to transcript operations, paralleling the +MCP tools in ``think/resources/transcripts.py`` but optimized for terminal use. + +Auto-discovered by ``think.call`` and mounted as ``sol call transcripts ...``. +""" + +import typer + +from think.cluster import ( + cluster, + cluster_period, + cluster_range, + cluster_scan, + cluster_segments, +) +from think.utils import day_dirs + +app = typer.Typer(help="Transcript browsing.") + + +@app.command("scan") +def scan(day: str = typer.Argument(help="Day (YYYYMMDD).")) -> None: + """List transcript coverage ranges for a day.""" + audio_ranges, screen_ranges = cluster_scan(day) + + typer.echo("Audio:") + if audio_ranges: + for start, end in audio_ranges: + typer.echo(f" {start} - {end}") + else: + typer.echo(" (none)") + + typer.echo("Screen:") + if screen_ranges: + for start, end in screen_ranges: + typer.echo(f" {start} - {end}") + else: + typer.echo(" (none)") + + +@app.command("segments") +def segments(day: str = typer.Argument(help="Day (YYYYMMDD).")) -> None: + """List recording segments for a day.""" + segment_list = cluster_segments(day) + if not segment_list: + typer.echo("No segments.") + return + + for segment in segment_list: + key = segment.get("key", "") + start = segment.get("start", "") + end = segment.get("end", "") + types = ", ".join(segment.get("types", [])) + typer.echo(f"{key} {start} - {end} [{types}]") + + +@app.command("read") +def read( + day: str = typer.Argument(help="Day (YYYYMMDD)."), + start: str | None = typer.Option(None, "--start", help="Start time (HHMMSS)."), + length: int | None = typer.Option(None, "--length", help="Length in minutes."), + segment: str | None = typer.Option( + None, "--segment", help="Segment key (HHMMSS_LEN)." + ), + full: bool = typer.Option( + False, "--full", help="Include audio, screen, and agents." + ), + raw: bool = typer.Option(False, "--raw", help="Include audio and screen only."), + audio: bool = typer.Option(False, "--audio", help="Include audio transcripts."), + screen: bool = typer.Option(False, "--screen", help="Include screen transcripts."), + agents: bool = typer.Option(False, "--agents", help="Include agent outputs."), +) -> None: + """Read transcript content for a day, segment, or time range.""" + if full and raw: + typer.echo("Error: Cannot use --full and --raw together.", err=True) + raise typer.Exit(1) + + if (full or raw) and (audio or screen or agents): + typer.echo( + "Error: Cannot mix --full/--raw with individual source flags.", err=True + ) + raise typer.Exit(1) + + if full: + sources: dict[str, bool] = {"audio": True, "screen": True, "agents": True} + elif raw: + sources = {"audio": True, "screen": True, "agents": False} + elif audio or screen or agents: + sources = {"audio": audio, "screen": screen, "agents": agents} + else: + sources = {"audio": True, "screen": False, "agents": True} + + if segment and (start or length is not None): + typer.echo("Error: Cannot mix --segment with --start/--length.", err=True) + raise typer.Exit(1) + + if (start is not None) != (length is not None): + typer.echo("Error: --start and --length must be used together.", err=True) + raise typer.Exit(1) + + if start is not None and length is not None: + from datetime import datetime, timedelta + + start_dt = datetime.strptime(start, "%H%M%S") + end_dt = start_dt + timedelta(minutes=length) + markdown = cluster_range(day, start, end_dt.strftime("%H%M%S"), sources) + elif segment is not None: + markdown, _counts = cluster_period(day, segment, sources) + else: + markdown, _counts = cluster(day, sources) + + typer.echo(markdown) + + +@app.command("stats") +def stats(month: str = typer.Argument(help="Month (YYYYMM).")) -> None: + """Show daily transcript coverage counts for a month.""" + days = sorted(day for day in day_dirs().keys() if day.startswith(month)) + + days_with_data = 0 + for day in days: + audio_ranges, screen_ranges = cluster_scan(day) + if audio_ranges or screen_ranges: + days_with_data += 1 + typer.echo(f"{day} audio:{len(audio_ranges)} screen:{len(screen_ranges)}") + + if not days_with_data: + typer.echo(f"No data for {month}.") + return + + typer.echo("") + typer.echo(f"Total: {days_with_data} days with data") diff --git a/apps/transcripts/tests/__init__.py b/apps/transcripts/tests/__init__.py new file mode 100644 index 000000000..f2c5b4f39 --- /dev/null +++ b/apps/transcripts/tests/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc diff --git a/apps/transcripts/tests/conftest.py b/apps/transcripts/tests/conftest.py new file mode 100644 index 000000000..8fe550a19 --- /dev/null +++ b/apps/transcripts/tests/conftest.py @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Fixtures for transcripts app tests.""" + +import os + +import pytest + + +@pytest.fixture(autouse=True) +def _journal_env(monkeypatch): + """Point JOURNAL_PATH at the test fixtures.""" + monkeypatch.setenv("JOURNAL_PATH", os.path.join(os.getcwd(), "fixtures", "journal")) diff --git a/apps/transcripts/tests/test_call.py b/apps/transcripts/tests/test_call.py new file mode 100644 index 000000000..a430cf04a --- /dev/null +++ b/apps/transcripts/tests/test_call.py @@ -0,0 +1,105 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Tests for transcripts CLI commands (sol call transcripts ...).""" + +from typer.testing import CliRunner + +from think.call import call_app + +runner = CliRunner() + + +class TestScan: + def test_scan_day(self): + result = runner.invoke(call_app, ["transcripts", "scan", "20240101"]) + assert result.exit_code == 0 + assert "Audio:" in result.output + assert "Screen:" in result.output + + def test_scan_empty_day(self): + result = runner.invoke(call_app, ["transcripts", "scan", "20990101"]) + assert result.exit_code == 0 + assert "(none)" in result.output + + +class TestSegments: + def test_segments_day(self): + result = runner.invoke(call_app, ["transcripts", "segments", "20240101"]) + assert result.exit_code == 0 + assert "123456_300" in result.output + + def test_segments_empty(self): + result = runner.invoke(call_app, ["transcripts", "segments", "20990101"]) + assert result.exit_code == 0 + assert "No segments" in result.output + + +class TestRead: + def test_read_default(self): + result = runner.invoke(call_app, ["transcripts", "read", "20240101"]) + assert result.exit_code == 0 + assert "## " in result.output + + def test_read_full(self): + result = runner.invoke(call_app, ["transcripts", "read", "20240101", "--full"]) + assert result.exit_code == 0 + + def test_read_raw(self): + result = runner.invoke(call_app, ["transcripts", "read", "20240101", "--raw"]) + assert result.exit_code == 0 + + def test_read_segment(self): + result = runner.invoke( + call_app, ["transcripts", "read", "20240101", "--segment", "123456_300"] + ) + assert result.exit_code == 0 + + def test_read_range(self): + result = runner.invoke( + call_app, + ["transcripts", "read", "20240101", "--start", "123456", "--length", "5"], + ) + assert result.exit_code == 0 + + def test_read_full_and_raw_error(self): + result = runner.invoke( + call_app, ["transcripts", "read", "20240101", "--full", "--raw"] + ) + assert result.exit_code == 1 + assert "Cannot use --full and --raw" in result.output + + def test_read_start_without_length(self): + result = runner.invoke( + call_app, ["transcripts", "read", "20240101", "--start", "123456"] + ) + assert result.exit_code == 1 + assert "--start and --length must be used together" in result.output + + def test_read_segment_with_start(self): + result = runner.invoke( + call_app, + [ + "transcripts", + "read", + "20240101", + "--segment", + "123456_300", + "--start", + "123456", + ], + ) + assert result.exit_code == 1 + + +class TestStats: + def test_stats_month(self): + result = runner.invoke(call_app, ["transcripts", "stats", "202401"]) + assert result.exit_code == 0 + assert "20240101" in result.output + assert "Total: 1 days with data" in result.output + + def test_stats_empty(self): + result = runner.invoke(call_app, ["transcripts", "stats", "209901"]) + assert result.exit_code == 0 + assert "No data" in result.output diff --git a/docs/THINK.md b/docs/THINK.md index f6e3708d3..1a5e2e410 100644 --- a/docs/THINK.md +++ b/docs/THINK.md @@ -14,8 +14,8 @@ All dependencies are listed in `pyproject.toml`. The package exposes several commands: -- `sol cluster` groups audio and screen JSON files into report sections. Use `--start` and - `--length` to limit the report to a specific time range. +- `sol call transcripts read` groups audio and screen transcripts into report sections. Use `--start` and + `--length` to limit the report to a specific time range. See `sol call transcripts --help` for additional commands. - `sol dream` runs generators and agents for a single day via Cortex. - `sol agents` is the unified CLI for tool agents and generators (spawned by Cortex, NDJSON protocol). - `sol supervisor` monitors observation heartbeats. Use `--no-observers` to disable local capture (sense still runs for remote uploads and imports). @@ -24,7 +24,7 @@ The package exposes several commands: - `sol muse` lists available agents and generators with their configuration. Use `sol muse ` to see details, and `sol muse --prompt` to see the fully composed prompt that would be sent to the LLM. ```bash -sol cluster YYYYMMDD [--start HHMMSS --length MINUTES] +sol call transcripts read YYYYMMDD [--start HHMMSS --length MINUTES] sol dream [--day YYYYMMDD] [--segment HHMMSS_LEN] [--force] [--run NAME] sol supervisor [--no-observers] sol mcp [--transport http] [--port PORT] [--path PATH] @@ -271,4 +271,3 @@ See [APPS.md](APPS.md#instructions-configuration) for the `instructions` schema - [CORTEX.md](CORTEX.md) - Full API, event schemas, request format - [CALLOSUM.md](CALLOSUM.md) - Message bus protocol - [THINK.md](THINK.md) - Cortex usage examples - diff --git a/sol.py b/sol.py index 2343e7c5b..45368e78c 100644 --- a/sol.py +++ b/sol.py @@ -39,7 +39,6 @@ import setproctitle COMMANDS: dict[str, str] = { # think package - daily processing and analysis "import": "think.importer", - "cluster": "think.cluster", "dream": "think.dream", "planner": "think.planner", "indexer": "think.indexer", @@ -90,7 +89,6 @@ ALIASES: dict[str, tuple[str, list[str]]] = { GROUPS: dict[str, list[str]] = { "Think (daily processing)": [ "import", - "cluster", "dream", "planner", "indexer", diff --git a/tests/test_cluster_full.py b/tests/test_cluster_full.py index 259c11141..17bea3002 100644 --- a/tests/test_cluster_full.py +++ b/tests/test_cluster_full.py @@ -40,27 +40,27 @@ def test_cluster_full(tmp_path, monkeypatch): assert "### audio summary" in md -def test_cluster_cli(tmp_path, monkeypatch, capsys): +def test_cluster_default_sources(tmp_path, monkeypatch): mod = importlib.import_module("think.cluster") copy_day(tmp_path) monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) - monkeypatch.setattr("sys.argv", ["cluster", "20240101"]) - mod.main() - out = capsys.readouterr().out + out, _counts = mod.cluster( + "20240101", sources={"audio": True, "screen": False, "agents": True} + ) # Now uses insight format: "### {stem} summary" assert "### screen summary" in out -def test_cluster_cli_range(tmp_path, monkeypatch, capsys): +def test_cluster_range_raw_screen(tmp_path, monkeypatch): mod = importlib.import_module("think.cluster") copy_day(tmp_path) monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) - monkeypatch.setattr( - "sys.argv", - ["cluster", "20240101", "--start", "123456", "--length", "1"], + out = mod.cluster_range( + "20240101", + "123456", + "123556", + sources={"audio": True, "screen": True, "agents": False}, ) - mod.main() - out = capsys.readouterr().out - # CLI --start/--length uses raw screen data (screen=True) + # Range mode with screen=True uses raw screen data. assert "### Screen Activity" in out assert "IDE with auth.py open" in out diff --git a/think/cluster.py b/think/cluster.py index aa19679c0..ffc6e68d4 100644 --- a/think/cluster.py +++ b/think/cluster.py @@ -1,7 +1,6 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -import argparse import os import re import sys @@ -12,7 +11,7 @@ from typing import Any from observe.screen import format_screen_text -from .utils import day_path, setup_cli +from .utils import day_path def _date_str(day_dir: str) -> str: @@ -647,49 +646,3 @@ def cluster_range( ] groups = _group_entries(entries) return _groups_to_markdown(groups) - - -def main(): - parser = argparse.ArgumentParser( - description="Generate a Markdown report for a day's JSON files grouped by recording segments." - ) - parser.add_argument( - "day", - help="Day in YYYYMMDD format", - ) - parser.add_argument( - "--start", - metavar="HHMMSS", - help="Start time for range (HHMMSS)", - ) - parser.add_argument( - "--length", - type=int, - help="Length of range in minutes", - ) - - args = setup_cli(parser) - - if args.start and args.length is not None: - start_dt = datetime.strptime(args.start, "%H%M%S") - end_dt = start_dt + timedelta(minutes=args.length) - # CLI range view: show raw data (audio + screen, no summaries) - markdown = cluster_range( - args.day, - args.start, - end_dt.strftime("%H%M%S"), - sources={"audio": True, "screen": True, "agents": False}, - ) - print(markdown) - elif args.start or args.length is not None: - parser.error("--start and --length must be used together") - else: - # CLI default: show audio + agent summaries (daily view) - markdown, _counts = cluster( - args.day, sources={"audio": True, "screen": False, "agents": True} - ) - print(markdown) - - -if __name__ == "__main__": - main()