uro-cli 0.6.0__tar.gz → 0.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: uro-cli
3
- Version: 0.6.0
3
+ Version: 0.8.0
4
4
  Summary: Uro Engine reference client — play, dry-run, and dev tools against the engine
5
5
  Project-URL: Homepage, https://github.com/cupskeee/uro
6
6
  Project-URL: Repository, https://github.com/cupskeee/uro
@@ -12,8 +12,8 @@ Requires-Python: >=3.12
12
12
  Requires-Dist: python-dotenv>=1.0
13
13
  Requires-Dist: rich>=13.9
14
14
  Requires-Dist: typer>=0.27.2
15
- Requires-Dist: uro-core[llm,postgres]==0.6.0
16
- Requires-Dist: uro-server==0.6.0
15
+ Requires-Dist: uro-core[llm,postgres]==0.8.0
16
+ Requires-Dist: uro-server==0.8.0
17
17
  Requires-Dist: websockets>=16.1
18
18
  Description-Content-Type: text/markdown
19
19
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "uro-cli"
3
- version = "0.6.0"
3
+ version = "0.8.0"
4
4
  description = "Uro Engine reference client — play, dry-run, and dev tools against the engine"
5
5
  license = "MIT"
6
6
  license-files = ["LICENSE"]
@@ -13,8 +13,8 @@ requires-python = ">=3.12"
13
13
  # pair a new uro-cli with an old uro-core; `just release` bumps all three together, and the
14
14
  # `{workspace = true}` sources make a drifted pin fail `uv sync` locally.
15
15
  dependencies = [
16
- "uro-core[postgres,llm]==0.6.0",
17
- "uro-server==0.6.0",
16
+ "uro-core[postgres,llm]==0.8.0",
17
+ "uro-server==0.8.0",
18
18
  "typer>=0.27.2",
19
19
  "rich>=13.9",
20
20
  "websockets>=16.1",
@@ -0,0 +1,133 @@
1
+ """`uro connect`, the reference play client, against a real server on a local port.
2
+
3
+ Its prompt used to come back the moment an intent was sent: it sat above the streaming narration,
4
+ and nothing was on screen while the beat was still being extracted and committed, so a player in
5
+ the first live run (2026-10-31) retyped an intent and played it twice.
6
+ """
7
+
8
+ import asyncio
9
+ import socket
10
+ import threading
11
+ import time
12
+ from collections.abc import AsyncIterator
13
+
14
+ import uvicorn
15
+ from typer.testing import CliRunner
16
+ from uro_cli.main import app
17
+ from uro_server.app import ServerDeps, create_app
18
+
19
+
20
+ def _free_port() -> int:
21
+ with socket.socket() as s:
22
+ s.bind(("127.0.0.1", 0))
23
+ return int(s.getsockname()[1])
24
+
25
+
26
+ def test_the_prompt_waits_for_each_beat_to_settle() -> None:
27
+ started: list[str] = []
28
+
29
+ async def run_beat(campaign_id: str, participant: str, text: str) -> AsyncIterator[str]:
30
+ started.append(text)
31
+ for word in ("The ", "tide ", "turns."):
32
+ await asyncio.sleep(0.05)
33
+ yield word
34
+ await asyncio.sleep(0.3) # extraction: the narration is done, the beat isn't
35
+
36
+ async def campaign_exists(campaign_id: str) -> bool:
37
+ return True
38
+
39
+ deps = ServerDeps(
40
+ resolve_participant={"tok": "player-1"}.get,
41
+ campaign_exists=campaign_exists,
42
+ run_beat=run_beat,
43
+ )
44
+ port = _free_port()
45
+ server = uvicorn.Server(
46
+ uvicorn.Config(create_app(deps), host="127.0.0.1", port=port, log_level="warning")
47
+ )
48
+ thread = threading.Thread(target=server.run, daemon=True)
49
+ thread.start()
50
+ while not server.started:
51
+ time.sleep(0.02)
52
+ try:
53
+ result = CliRunner().invoke(
54
+ app,
55
+ ["connect", "camp-1", "--server", f"http://127.0.0.1:{port}", "--token", "tok"],
56
+ input="first\nsecond\n/quit\n", # typed ahead, as an impatient player would
57
+ )
58
+ finally:
59
+ server.should_exit = True
60
+ thread.join(5)
61
+ out = result.output
62
+ assert result.exit_code == 0, out
63
+ assert started == ["first", "second"]
64
+ prompts = [i for i in range(len(out)) if out.startswith("> ", i)]
65
+ first, second = out.index("✓ [player-1] 'first'"), out.index("✓ [player-1] 'second'")
66
+ assert len(prompts) == 3, out
67
+ assert first < prompts[1] < second < prompts[2], out # each prompt only after its beat settled
68
+
69
+
70
+ def test_another_players_identical_intent_never_settles_ours() -> None:
71
+ # Review: the client matched outcomes by intent TEXT. In a party, another player typing the same
72
+ # words while our beat ran got `not_your_turn`, broadcast to everyone, and released our prompt
73
+ # mid-beat. Intents now carry an id the server echoes back.
74
+ import json
75
+
76
+ import websockets
77
+ from uro_core.session import PartyArbiter
78
+
79
+ async def run_beat(campaign_id: str, participant: str, text: str) -> AsyncIterator[str]:
80
+ yield "The "
81
+ await asyncio.sleep(1.2)
82
+ yield "end."
83
+
84
+ async def campaign_exists(campaign_id: str) -> bool:
85
+ return True
86
+
87
+ deps = ServerDeps(
88
+ resolve_participant={"tok": "player-1", "tok2": "player-2"}.get,
89
+ campaign_exists=campaign_exists,
90
+ run_beat=run_beat,
91
+ )
92
+ port = _free_port()
93
+ server = uvicorn.Server(
94
+ uvicorn.Config(
95
+ create_app(deps, arbiter=PartyArbiter()),
96
+ host="127.0.0.1",
97
+ port=port,
98
+ log_level="warning",
99
+ )
100
+ )
101
+ thread = threading.Thread(target=server.run, daemon=True)
102
+ thread.start()
103
+ while not server.started:
104
+ time.sleep(0.02)
105
+
106
+ def player_two() -> None: # joins second (so player-1 holds the turn), same words mid-beat
107
+ async def go() -> None:
108
+ await asyncio.sleep(0.3)
109
+ uri = f"ws://127.0.0.1:{port}/campaigns/c/play?token=tok2"
110
+ async with websockets.connect(uri) as ws:
111
+ await asyncio.sleep(0.2)
112
+ await ws.send(json.dumps({"type": "intent", "text": "look around"}))
113
+ await asyncio.sleep(1.5)
114
+
115
+ asyncio.run(go())
116
+
117
+ other = threading.Thread(target=player_two)
118
+ other.start()
119
+ try:
120
+ result = CliRunner().invoke(
121
+ app,
122
+ ["connect", "c", "--server", f"http://127.0.0.1:{port}", "--token", "tok"],
123
+ input="look around\n/quit\n",
124
+ )
125
+ finally:
126
+ other.join()
127
+ server.should_exit = True
128
+ thread.join(5)
129
+ out = result.output
130
+ assert "not your turn" in out, out # player-2's identical intent did reach us...
131
+ ours = out.index("✓ [player-1] 'look around'") # ...but our prompt waited for OUR beat
132
+ prompts = [i for i in range(len(out)) if out.startswith("> ", i)]
133
+ assert len(prompts) == 2 and prompts[1] > ours, out
@@ -11,7 +11,7 @@ import json
11
11
  import sys
12
12
  import time
13
13
  import webbrowser
14
- from collections.abc import Awaitable
14
+ from collections.abc import AsyncIterator, Awaitable
15
15
  from pathlib import Path
16
16
  from typing import Any
17
17
 
@@ -24,7 +24,7 @@ from uro_core.providers.router import ProviderRouter
24
24
  from uro_core.rulesets import registry
25
25
  from uro_core.rulesets.base import CharSpec, Ruleset
26
26
  from uro_core.rulesets.rng import Rng
27
- from uro_core.timeline.models import STARTING_GEAR, World
27
+ from uro_core.timeline.models import FORK_TIME_SKIP, STARTING_GEAR, World
28
28
 
29
29
  from uro_cli.wiring import (
30
30
  build_router,
@@ -88,6 +88,27 @@ _ROLES = tuple(sorted(ROLES))
88
88
  PARTICIPANT = "player-1" # Phase 0 is single-player; participants arrive in Phase 5.
89
89
 
90
90
 
91
+ @contextlib.asynccontextmanager
92
+ async def _metered(router: ProviderRouter) -> AsyncIterator[ProviderRouter]:
93
+ """Meter a command's model calls when the database is reachable (D-66). Best effort: `world
94
+ backfill` and `world probe` work on a pack file alone, so a down database never stops them —
95
+ their calls just go unrecorded."""
96
+ store: PostgresEventStore | None = None
97
+ try:
98
+ connecting = build_store()
99
+ await asyncio.wait_for(connect_store(connecting), timeout=5)
100
+ store = connecting
101
+ except Exception:
102
+ pass # no database: run unmetered
103
+ if store is not None:
104
+ router.recorder = store.record_llm_call
105
+ try:
106
+ yield router
107
+ finally:
108
+ if store is not None:
109
+ await store.close()
110
+
111
+
91
112
  def _run_async(coro_factory) -> None: # type: ignore[no-untyped-def]
92
113
  """Run an async command, turning config/credential/lookup errors into a clean message."""
93
114
  try:
@@ -278,6 +299,8 @@ def world_create(
278
299
  needs_fill = backfill and check_sufficiency(pack).grade != "runnable"
279
300
  router = build_router(provider, model) if needs_fill else None
280
301
  await connect_store(store)
302
+ if router is not None: # the backfill's calls are metered like any other (D-66)
303
+ router.recorder = store.record_llm_call
281
304
  result = await import_pack(store, pack, backfill_router=router)
282
305
  except PackError as exc:
283
306
  typer.echo(f"error: {exc}", err=True)
@@ -353,7 +376,8 @@ def world_backfill(
353
376
  if before.grade == "runnable":
354
377
  typer.echo("pack is already runnable — nothing to backfill")
355
378
  return
356
- augmented, added = await backfill_gaps(pack, build_router(provider, model), report=before)
379
+ async with _metered(build_router(provider, model)) as router:
380
+ augmented, added = await backfill_gaps(pack, router, report=before)
357
381
  after = check_sufficiency(augmented)
358
382
  typer.echo(f"backfill: {before.grade} → {after.grade}")
359
383
  for a in added:
@@ -384,7 +408,8 @@ def world_probe(
384
408
  except PackError as exc:
385
409
  typer.echo(f"error: {exc}", err=True)
386
410
  raise typer.Exit(1) from exc
387
- report = await run_probes(pack.manifest, build_router(provider, model), tries=tries)
411
+ async with _metered(build_router(provider, model)) as router:
412
+ report = await run_probes(pack.manifest, router, tries=tries)
388
413
  typer.echo(f"probe report for {report.world}: {'OK' if report.ok else 'ISSUES'}")
389
414
  for r in report.results:
390
415
  gate = f" (gates {r.gate_for})" if r.gate_for else ""
@@ -552,6 +577,13 @@ def serve(
552
577
  "(consensus). UNSET = auto (solo for 1 token, party for >1). Setting it EXPLICITLY binds "
553
578
  "that shape even for a 1-token launch, so a runtime-added player still gets turns (D-39).",
554
579
  ),
580
+ nats_url: str = typer.Option(
581
+ None,
582
+ "--nats",
583
+ help="push every commit — from any process — to WebSocket clients live, via the NATS "
584
+ "subjects `uro outbox relay` publishes to (D-67)",
585
+ ),
586
+ subject_prefix: str = typer.Option("uro", "--subject-prefix", help="the relay's prefix"),
555
587
  cors_origin: list[str] = typer.Option( # noqa: B008
556
588
  None,
557
589
  "--cors-origin",
@@ -616,18 +648,29 @@ def serve(
616
648
  else:
617
649
  arbiter = shapes[arbiter_kind]()
618
650
  deps = engine_deps(store, engine, tokens, admin_tokens, router=router)
619
- fastapi_app = create_app(deps, arbiter=arbiter, cors_origins=cors_origin or None)
651
+ commit_feed = None
652
+ if nats_url:
653
+ from uro_core.adapters.nats import NatsCommitFeed
654
+
655
+ commit_feed = NatsCommitFeed(nats_url, subject_prefix=subject_prefix)
620
656
 
621
- @fastapi_app.on_event("startup")
622
657
  async def _startup() -> None:
623
658
  await connect_store(store)
624
659
  if deps.hydrate_tokens is not None: # load durable runtime tokens (D-39) — AFTER connect
625
660
  await deps.hydrate_tokens()
626
661
 
627
- @fastapi_app.on_event("shutdown")
628
662
  async def _shutdown() -> None:
629
663
  await store.close()
630
664
 
665
+ fastapi_app = create_app(
666
+ deps,
667
+ arbiter=arbiter,
668
+ cors_origins=cors_origin or None,
669
+ commit_feed=commit_feed,
670
+ startup=_startup,
671
+ shutdown=_shutdown,
672
+ )
673
+
631
674
  # (The DB preflight is folded into _resolve_router above — its connect_store gives the same
632
675
  # clean docker-first hint on a down DB, and the pool it opens is closed before uvicorn starts;
633
676
  # the startup hook re-opens one on uvicorn's own loop.)
@@ -641,6 +684,8 @@ def serve(
641
684
  typer.echo(f" token {t!r} → {p}")
642
685
  if cors_origin:
643
686
  typer.echo(f" CORS: allowing {', '.join(cors_origin)}")
687
+ if nats_url:
688
+ typer.echo(f" live commits: {nats_url} ({subject_prefix}.>) — run `uro outbox relay` too")
644
689
  uvicorn.run(fastapi_app, host=host, port=port, log_level="warning")
645
690
 
646
691
 
@@ -656,6 +701,7 @@ def connect(
656
701
  the campaign stream in live."""
657
702
  import asyncio
658
703
  import json
704
+ import uuid
659
705
 
660
706
  import websockets
661
707
 
@@ -666,11 +712,44 @@ def connect(
666
712
  try:
667
713
  async with websockets.connect(uri) as ws:
668
714
  typer.echo(f"connected to {campaign_id} as {token!r}. Type an action; /quit.\n")
715
+ # An intent's beat is SETTLED by one of these, each echoing the intent's text. The
716
+ # prompt waits for it: shown at once, it sat above the streaming narration and was
717
+ # gone by the time the beat committed (extraction runs after the narration), so a
718
+ # player retyped the intent and played it twice (live run, 2026-10-31).
719
+ settles = {
720
+ "beat_committed",
721
+ "beat_failed",
722
+ "not_your_turn",
723
+ "proposal_opened",
724
+ "intent_rejected",
725
+ }
726
+ # intent id → (its text, set when it settles). The server echoes the id as
727
+ # `intent_id`, so another player's identical text never settles ours. Only a server
728
+ # that has never echoed one (an older one) is matched by text; once one has, a frame
729
+ # without an id is another client's.
730
+ pending: dict[str, tuple[str, asyncio.Event]] = {}
731
+ echoes = False
732
+
733
+ def settle(msg: dict[str, Any]) -> None:
734
+ if "intent_id" in msg:
735
+ hit = pending.pop(str(msg["intent_id"]), None)
736
+ elif not echoes:
737
+ text = str(msg.get("intent", msg.get("text", "")))
738
+ rid = next((k for k, (t, _) in pending.items() if t == text), None)
739
+ hit = pending.pop(rid) if rid is not None else None
740
+ else:
741
+ hit = None
742
+ if hit is not None:
743
+ hit[1].set()
669
744
 
670
745
  async def receive() -> None:
746
+ nonlocal echoes
671
747
  async for raw in ws:
672
748
  msg = json.loads(raw)
673
749
  kind = msg.get("type")
750
+ echoes = echoes or "intent_id" in msg
751
+ if kind in settles:
752
+ settle(msg)
674
753
  if kind == "narration_chunk":
675
754
  typer.echo(msg["text"], nl=False)
676
755
  elif kind == "beat_committed":
@@ -699,6 +778,15 @@ def connect(
699
778
  )
700
779
  elif kind == "beat_failed":
701
780
  typer.echo(f"\n ✗ beat failed: {msg['error']}")
781
+ elif kind == "intent_rejected":
782
+ typer.echo(
783
+ f" ✗ [{msg['participant_id']}] intent rejected: {msg['text']!r}"
784
+ )
785
+ elif kind == "commit": # made elsewhere: another server, the CLI (D-67)
786
+ for beat in msg.get("beats") or []:
787
+ typer.echo(f"\n[{beat['participant_id']}] {beat['narration']}")
788
+ if not msg.get("beats"):
789
+ typer.echo("\n · the world changed (no new narration)")
702
790
  elif kind in ("participant_joined", "participant_left"):
703
791
  typer.echo(f" · {msg['participant_id']} {kind.split('_')[1]}")
704
792
 
@@ -713,7 +801,20 @@ def connect(
713
801
  elif intent.startswith("/vote "): # non-canon consensus vote (D-38)
714
802
  await ws.send(json.dumps({"type": "vote", "choice": intent[6:]}))
715
803
  elif intent:
716
- await ws.send(json.dumps({"type": "intent", "text": intent}))
804
+ rid = uuid.uuid4().hex
805
+ settled = asyncio.Event()
806
+ pending[rid] = (intent, settled)
807
+ await ws.send(json.dumps({"type": "intent", "text": intent, "id": rid}))
808
+ waiting = asyncio.ensure_future(settled.wait())
809
+ # a slow beat (a reasoning model's extraction) still settles well inside
810
+ # this; past it — or if the server goes away — the prompt comes back anyway
811
+ await asyncio.wait(
812
+ {waiting, receiver}, timeout=600, return_when=asyncio.FIRST_COMPLETED
813
+ )
814
+ waiting.cancel()
815
+ pending.pop(rid, None)
816
+ if receiver.done(): # the server closed the connection
817
+ break
717
818
  receiver.cancel()
718
819
  except OSError as exc:
719
820
  typer.echo(f"could not connect to {server}: {exc} (is `uro serve` running?)", err=True)
@@ -918,7 +1019,7 @@ def branch_fork(
918
1019
  pick = registry.resolve_for_branch(store, branch.branch_id)
919
1020
  ruleset = await _time_skip_ruleset(pick)
920
1021
  engine = Engine(store, build_router("stub", None), ruleset=ruleset)
921
- await engine.agenda_tick(branch.branch_id, time_skip_days)
1022
+ await engine.agenda_tick(branch.branch_id, time_skip_days, reason=FORK_TIME_SKIP)
922
1023
  finally:
923
1024
  await store.close()
924
1025
  forked = branch.forked_from if branch.forked_from else "-"
@@ -1174,15 +1275,29 @@ def chronicle(
1174
1275
  _run_async(_run)
1175
1276
 
1176
1277
 
1278
+ async def _call_scope(
1279
+ store: PostgresEventStore, world: str | None, campaign: str | None
1280
+ ) -> tuple[str | None, list[str] | None]:
1281
+ """--world / --campaign → (world_id, branch_ids); a campaign's calls are its branch's (D-66)."""
1282
+ world_id = (await _world_or_exit(store, world)).world_id if world else None
1283
+ branch_ids = [(await _campaign_or_exit(store, campaign)).branch_id] if campaign else None
1284
+ return world_id, branch_ids
1285
+
1286
+
1177
1287
  @app.command()
1178
- def usage(stage: str = typer.Option(None, "--stage", help="only this pipeline stage")) -> None:
1179
- """LLM-call telemetry by stage: calls, models, tokens (0 until metered), average latency."""
1288
+ def usage(
1289
+ stage: str = typer.Option(None, "--stage", help="only this pipeline stage"),
1290
+ world: str = typer.Option(None, "--world", help="only calls that served this world"),
1291
+ campaign: str = typer.Option(None, "--campaign", help="only calls on this campaign's branch"),
1292
+ ) -> None:
1293
+ """LLM-call telemetry by stage: calls, models, token counts, average latency (D-66)."""
1180
1294
 
1181
1295
  async def _run() -> None:
1182
1296
  store = build_store()
1183
1297
  await connect_store(store)
1184
1298
  try:
1185
- rows = await store.usage_by_stage(stage)
1299
+ world_id, branch_ids = await _call_scope(store, world, campaign)
1300
+ rows = await store.usage_by_stage(stage, world_id=world_id, branch_ids=branch_ids)
1186
1301
  finally:
1187
1302
  await store.close()
1188
1303
  typer.echo(f"total calls: {sum(int(r['calls']) for r in rows)}")
@@ -1192,6 +1307,41 @@ def usage(stage: str = typer.Option(None, "--stage", help="only this pipeline st
1192
1307
  _run_async(_run)
1193
1308
 
1194
1309
 
1310
+ @app.command()
1311
+ def calls(
1312
+ stage: str = typer.Option(None, "--stage", help="e.g. extractor, planner"),
1313
+ world: str = typer.Option(None, "--world", help="only calls that served this world"),
1314
+ campaign: str = typer.Option(None, "--campaign", help="only calls on this campaign's branch"),
1315
+ limit: int = typer.Option(5, "--limit", min=1, max=200, help="how many, newest first"),
1316
+ ) -> None:
1317
+ """Recent LLM calls with what each structured one returned (D-66) — e.g. the extractor's raw
1318
+ JSON for a beat that committed less than its narration said."""
1319
+
1320
+ async def _run() -> None:
1321
+ store = build_store()
1322
+ await connect_store(store)
1323
+ try:
1324
+ world_id, branch_ids = await _call_scope(store, world, campaign)
1325
+ rows = await store.recent_llm_calls(
1326
+ stage=stage, world_id=world_id, branch_ids=branch_ids, limit=limit
1327
+ )
1328
+ finally:
1329
+ await store.close()
1330
+ if not rows:
1331
+ typer.echo("(no metered calls)")
1332
+ for r in rows:
1333
+ tokens = f"{r['tokens_in'] or '?'}→{r['tokens_out'] or '?'} tokens"
1334
+ typer.echo(
1335
+ f"{r['created_at']:%Y-%m-%d %H:%M:%S} {r['stage_tag']:<10} {r['model'] or '-'} "
1336
+ f"{tokens} {r['latency_ms']}ms"
1337
+ + (f" FAILED: {r['error']}" if r.get("error") else "")
1338
+ )
1339
+ if r["response"] is not None:
1340
+ typer.echo(f" {r['response']}")
1341
+
1342
+ _run_async(_run)
1343
+
1344
+
1195
1345
  @ruleset_app.command("list")
1196
1346
  def ruleset_list() -> None:
1197
1347
  """The rulesets a world can declare (`[ruleset] id` in world.toml): the built-ins and installed
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes