Spaces:
Sleeping
Sleeping
| """Typer CLI entrypoint and command routing (Section 11). | |
| Commands: | |
| id new --seed "<prompt>" [--n 3] [--profile cheap|quality] | |
| id worlds | |
| id play <world_id> | |
| id resume <session_id> | |
| id costs <world_id|session_id> | |
| In-session verbs (explicit, lower-risk; free-text intent left for later): | |
| talk <name> <message> look [@<location>] <query> | |
| confront <name> <A> <B> 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 | |
| # -------------------------------------------------------------------------- | |
| 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 | |
| # -------------------------------------------------------------------------- | |
| 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 | |
| # -------------------------------------------------------------------------- | |
| 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) | |
| 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 | |
| # -------------------------------------------------------------------------- | |
| 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 <name> <message> Question a character. | |
| look [@<location>] <query> Ask the world (e.g. look @study under the desk). | |
| confront <name> <A> <B> 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 <name> <message>[/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] <query>[/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 <name> <statementA_id> <statementB_id>[/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() | |