"""Typer CLI entrypoint and command routing (Section 11). Commands: id new --seed "" [--n 3] [--profile cheap|quality] id worlds id play id resume id costs In-session verbs (explicit, lower-risk; free-text intent left for later): talk look [@] confront notes accuse who help quit """ from __future__ import annotations import shlex from pathlib import Path import typer from rich.console import Console from rich.panel import Panel from rich.table import Table from .config import Config, load_config, load_prices from .engine.loop import Session from .generator.pipeline import generate_world from .llm.client import LLMClient from .llm.prompts import PromptRegistry from .llm.usage import aggregate, estimate_cost app = typer.Typer(add_completion=False, help="ID — an LLM-driven investigation game.") console = Console() def _ctx(profile: str | None = None) -> tuple[Config, PromptRegistry, LLMClient]: cfg = load_config().with_profile(profile) prompts = PromptRegistry(cfg.prompts_dir) client = LLMClient(cfg) return cfg, prompts, client # -------------------------------------------------------------------------- # id new # -------------------------------------------------------------------------- @app.command() def new( seed: str = typer.Option(..., "--seed", help="Premise prompt for the case."), n: int = typer.Option(None, "--n", help="Candidate concepts (best-of-n)."), profile: str | None = typer.Option(None, "--profile", help="cheap|quality"), ) -> None: """Generate + validate a world, add it to the archive.""" cfg, prompts, client = _ctx(profile) n_eff = n or cfg.engine.best_of_n # generation logs usage against the world; bind a temp ledger at world dir result = generate_world( config=cfg, client=client, prompts=prompts, seed=seed, n=n_eff, on_event=lambda m: console.print(f"[dim]{m}[/dim]"), ) if result.shipped: # attach the world-scoped usage ledger retroactively is handled inside console.print(Panel.fit( f"[green]Shipped world[/green] [bold]{result.world_id}[/bold]\n" f"attempts: {result.attempts}\n" f"culprit (solver): {result.report.named_culprit}", title="id new", )) console.print(f"Play it with: [bold]id play {result.world_id}[/bold]") else: console.print(Panel.fit( f"[red]Generation failed the solvability/fairness gate " f"after {result.attempts} attempt(s).[/red]\n" f"last solver: solved={result.report.solved} " f"unique={result.report.unique} fair={result.report.fair}\n" f"{result.report.fairness_detail}\n{result.report.notes}", title="id new", )) raise typer.Exit(1) # -------------------------------------------------------------------------- # id worlds # -------------------------------------------------------------------------- @app.command() def worlds() -> None: """List archived worlds.""" cfg, _, _ = _ctx() from .generator.archive import Archive entries = Archive(cfg.worlds_dir).entries() if not entries: console.print("[dim]No worlds yet. Generate one with `id new --seed ...`.[/dim]") return table = Table(title="Archived worlds") table.add_column("world_id", style="bold cyan") table.add_column("one_line") table.add_column("twist", style="magenta") table.add_column("plays", justify="right") for e in entries: table.add_row( e.get("world_id", ""), e.get("one_line", ""), e.get("twist_tag", ""), str(e.get("play_count", 0)), ) console.print(table) # -------------------------------------------------------------------------- # id play / resume # -------------------------------------------------------------------------- @app.command() def play(world_id: str, profile: str | None = typer.Option(None, "--profile")) -> None: """Start a session for a world.""" cfg, prompts, client = _ctx(profile) if not (cfg.worlds_dir / world_id).exists(): console.print(f"[red]No such world: {world_id}[/red]") raise typer.Exit(1) session = Session.start(cfg, world_id, prompts, client) from .generator.archive import Archive Archive(cfg.worlds_dir).bump_play_count(world_id) _intro(session) _interactive(session) @app.command() def resume(session_id: str, profile: str | None = typer.Option(None, "--profile")) -> None: """Resume an existing session (reloads ledgers, delta, transcript).""" cfg, prompts, client = _ctx(profile) sdir = cfg.runtime_dir / session_id if not (sdir / "session.json").exists(): console.print(f"[red]No such session: {session_id}[/red]") raise typer.Exit(1) session = Session.resume(cfg, session_id, prompts, client) console.print(f"[green]Resumed[/green] {session_id} (turn {session.state.turn}).") _interactive(session) # -------------------------------------------------------------------------- # id costs # -------------------------------------------------------------------------- @app.command() def costs(target: str) -> None: """Token + (optional) cost report for a world_id or session_id.""" cfg, _, _ = _ctx() paths: list[Path] = [] sdir = cfg.runtime_dir / target if (sdir / "usage.jsonl").exists(): paths.append(sdir / "usage.jsonl") else: # treat as world_id: aggregate the world's gen ledger + all its sessions wdir = cfg.worlds_dir / target if (wdir / "usage.jsonl").exists(): paths.append(wdir / "usage.jsonl") if cfg.runtime_dir.exists(): for s in cfg.runtime_dir.iterdir(): if s.name.startswith(target) and (s / "usage.jsonl").exists(): paths.append(s / "usage.jsonl") if not paths: console.print(f"[yellow]No usage records found for {target!r}.[/yellow]") raise typer.Exit(1) report = aggregate(paths) prices = load_prices(cfg.prices_path) est = estimate_cost(report, prices) if prices else {} t1 = Table(title="Tokens by task") t1.add_column("task") t1.add_column("calls", justify="right") t1.add_column("prompt", justify="right") t1.add_column("completion", justify="right") t1.add_column("total", justify="right") for task, tot in sorted(report.by_task.items(), key=lambda kv: -kv[1].total): t1.add_row(task, str(tot.calls), str(tot.prompt), str(tot.completion), str(tot.total)) console.print(t1) t2 = Table(title="Tokens by model") t2.add_column("model") t2.add_column("total", justify="right") if est: t2.add_column("est. USD", justify="right") for model, tot in sorted(report.by_model.items(), key=lambda kv: -kv[1].total): if est: t2.add_row(model, str(tot.total), f"${est.get(model, 0.0):.4f}") else: t2.add_row(model, str(tot.total)) console.print(t2) g = report.grand line = f"[bold]Grand total[/bold]: {g.total} tokens over {g.calls} calls" if est: line += f" (~${sum(est.values()):.4f})" console.print(line) # -------------------------------------------------------------------------- # interactive session loop # -------------------------------------------------------------------------- def _intro(session: Session) -> None: w = session.world console.print(Panel(w.world_md.strip()[:1200] or w.meta.one_line, title=f"[bold]{w.meta.title or w.meta.world_id}[/bold]")) names = ", ".join( f"{c.name} ({c.role})" for c in w.characters.values() if c.role != "victim" ) console.print(f"[dim]People you can question:[/dim] {names}") console.print(f"[dim]Session:[/dim] {session.state.session_id}") console.print("[dim]Type `help` for verbs, `quit` to leave (progress is saved).[/dim]") HELP = """[bold]Verbs[/bold] talk Question a character. look [@] Ask the world (e.g. look @study under the desk). confront Pin two of their statement ids (see `notes`). notes Your case file: statements + clues found. who List people and locations. accuse Name culprit + means + motive + opportunity. help This. quit Save and exit. """ def _interactive(session: Session) -> None: while True: try: raw = console.input("\n[bold green]> [/bold green]").strip() except (EOFError, KeyboardInterrupt): console.print("\n[dim]Saved. Goodbye.[/dim]") return if not raw: continue try: parts = shlex.split(raw) except ValueError: parts = raw.split() verb = parts[0].lower() if verb in ("quit", "exit"): console.print("[dim]Saved. Goodbye.[/dim]") return if verb == "help": console.print(HELP) continue if verb == "who": _who(session) continue if verb == "notes": _notes(session) continue if verb == "talk": _talk(session, parts[1:]) continue if verb == "look": _look(session, parts[1:], raw) continue if verb == "confront": _confront(session, parts[1:]) continue if verb == "accuse": _accuse(session) if session.state.status.value != "active": return continue console.print(f"[yellow]Unknown verb: {verb}. Try `help`.[/yellow]") def _who(session: Session) -> None: locs = sorted({s.location for s in session.world.timeline.slices}) for c in session.world.characters.values(): tag = "victim" if c.role == "victim" else c.role console.print(f" [cyan]{c.name}[/cyan] — {tag}") if locs: console.print(f"[dim]Locations:[/dim] {', '.join(locs)}") def _talk(session: Session, args: list[str]) -> None: if len(args) < 2: console.print("[yellow]Usage: talk [/yellow]") return name, message = args[0], " ".join(args[1:]) try: outcome = session.talk(name, message) except KeyError as e: console.print(f"[red]{e}[/red]") return title = f"{name}" + (" — [red]cracking[/red]" if outcome.cracked else "") border = "red" if outcome.cracked else "blue" console.print(Panel(outcome.text, title=title, border_style=border)) for cid in outcome.discovered_clues: console.print(f"[bold yellow]★ Clue discovered:[/bold yellow] {cid}") def _look(session: Session, args: list[str], raw: str) -> None: location = None if args and args[0].startswith("@"): location = args[0][1:] query = " ".join(args[1:]) else: query = " ".join(args) if not query: console.print("[yellow]Usage: look [@location] [/yellow]") return answer = session.look(query, location) console.print(Panel(answer.text, title="The scene", border_style="green")) if answer.discovered_clue: console.print(f"[bold yellow]★ Clue discovered:[/bold yellow] {answer.discovered_clue}") def _confront(session: Session, args: list[str]) -> None: if len(args) != 3: console.print("[yellow]Usage: confront [/yellow]") console.print("[dim]Statement ids come from `notes`.[/dim]") return name, a, b = args try: result = session.confront(name, a, b) except KeyError as e: console.print(f"[red]{e}[/red]") return style = "bold red" if result.verified else "yellow" label = "VERIFIED CONTRADICTION" if result.verified else "no contradiction" console.print(Panel(result.reason, title=f"Confront {name}: {label}", border_style=style)) if result.verified: console.print("[dim]Press them now — this is leverage.[/dim]") def _notes(session: Session) -> None: notes = session.notes() if notes.discovered_clues: console.print("[bold]Clues discovered[/bold]") for c in notes.discovered_clues: console.print(f" ★ {c['id']}: {c['reveals']}") else: console.print("[dim]No clues discovered yet.[/dim]") if notes.cracked: console.print(f"[bold red]Cracked:[/bold red] {', '.join(notes.cracked)}") if notes.ledgers: console.print("\n[bold]Statements on record[/bold] [dim](id — proposition)[/dim]") for name, claims in notes.ledgers.items(): console.print(f"[cyan]{name}[/cyan]:") for cl in claims: console.print(f" [dim]{cl['claim_id']}[/dim] — {cl['proposition']}") else: console.print("[dim]No one has committed to anything yet.[/dim]") def _accuse(session: Session) -> None: console.print("[bold red]ACCUSATION[/bold red] — this ends the case.") culprit = console.input("Culprit: ").strip() if not culprit: console.print("[dim]Cancelled.[/dim]") return means = console.input("Means: ").strip() motive = console.input("Motive: ").strip() opportunity = console.input("Opportunity: ").strip() result = session.accuse(culprit, means, motive, opportunity) border = {"solved": "green", "right_culprit_unproven": "yellow", "wrong": "red"}[result.grade] console.print(Panel(result.debrief, title="Debrief", border_style=border)) if __name__ == "__main__": app()