uro-cli 0.6.0__tar.gz → 0.7.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.7.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.7.0
16
+ Requires-Dist: uro-server==0.7.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.7.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.7.0",
17
+ "uro-server==0.7.0",
18
18
  "typer>=0.27.2",
19
19
  "rich>=13.9",
20
20
  "websockets>=16.1",
@@ -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
 
@@ -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,7 +648,14 @@ 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)
656
+ fastapi_app = create_app(
657
+ deps, arbiter=arbiter, cors_origins=cors_origin or None, commit_feed=commit_feed
658
+ )
620
659
 
621
660
  @fastapi_app.on_event("startup")
622
661
  async def _startup() -> None:
@@ -641,6 +680,8 @@ def serve(
641
680
  typer.echo(f" token {t!r} → {p}")
642
681
  if cors_origin:
643
682
  typer.echo(f" CORS: allowing {', '.join(cors_origin)}")
683
+ if nats_url:
684
+ typer.echo(f" live commits: {nats_url} ({subject_prefix}.>) — run `uro outbox relay` too")
644
685
  uvicorn.run(fastapi_app, host=host, port=port, log_level="warning")
645
686
 
646
687
 
@@ -699,6 +740,11 @@ def connect(
699
740
  )
700
741
  elif kind == "beat_failed":
701
742
  typer.echo(f"\n ✗ beat failed: {msg['error']}")
743
+ elif kind == "commit": # made elsewhere: another server, the CLI (D-67)
744
+ for beat in msg.get("beats") or []:
745
+ typer.echo(f"\n[{beat['participant_id']}] {beat['narration']}")
746
+ if not msg.get("beats"):
747
+ typer.echo("\n · the world changed (no new narration)")
702
748
  elif kind in ("participant_joined", "participant_left"):
703
749
  typer.echo(f" · {msg['participant_id']} {kind.split('_')[1]}")
704
750
 
@@ -1174,15 +1220,29 @@ def chronicle(
1174
1220
  _run_async(_run)
1175
1221
 
1176
1222
 
1223
+ async def _call_scope(
1224
+ store: PostgresEventStore, world: str | None, campaign: str | None
1225
+ ) -> tuple[str | None, list[str] | None]:
1226
+ """--world / --campaign → (world_id, branch_ids); a campaign's calls are its branch's (D-66)."""
1227
+ world_id = (await _world_or_exit(store, world)).world_id if world else None
1228
+ branch_ids = [(await _campaign_or_exit(store, campaign)).branch_id] if campaign else None
1229
+ return world_id, branch_ids
1230
+
1231
+
1177
1232
  @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."""
1233
+ def usage(
1234
+ stage: str = typer.Option(None, "--stage", help="only this pipeline stage"),
1235
+ world: str = typer.Option(None, "--world", help="only calls that served this world"),
1236
+ campaign: str = typer.Option(None, "--campaign", help="only calls on this campaign's branch"),
1237
+ ) -> None:
1238
+ """LLM-call telemetry by stage: calls, models, token counts, average latency (D-66)."""
1180
1239
 
1181
1240
  async def _run() -> None:
1182
1241
  store = build_store()
1183
1242
  await connect_store(store)
1184
1243
  try:
1185
- rows = await store.usage_by_stage(stage)
1244
+ world_id, branch_ids = await _call_scope(store, world, campaign)
1245
+ rows = await store.usage_by_stage(stage, world_id=world_id, branch_ids=branch_ids)
1186
1246
  finally:
1187
1247
  await store.close()
1188
1248
  typer.echo(f"total calls: {sum(int(r['calls']) for r in rows)}")
@@ -1192,6 +1252,40 @@ def usage(stage: str = typer.Option(None, "--stage", help="only this pipeline st
1192
1252
  _run_async(_run)
1193
1253
 
1194
1254
 
1255
+ @app.command()
1256
+ def calls(
1257
+ stage: str = typer.Option(None, "--stage", help="e.g. extractor, planner"),
1258
+ world: str = typer.Option(None, "--world", help="only calls that served this world"),
1259
+ campaign: str = typer.Option(None, "--campaign", help="only calls on this campaign's branch"),
1260
+ limit: int = typer.Option(5, "--limit", min=1, max=200, help="how many, newest first"),
1261
+ ) -> None:
1262
+ """Recent LLM calls with what each structured one returned (D-66) — e.g. the extractor's raw
1263
+ JSON for a beat that committed less than its narration said."""
1264
+
1265
+ async def _run() -> None:
1266
+ store = build_store()
1267
+ await connect_store(store)
1268
+ try:
1269
+ world_id, branch_ids = await _call_scope(store, world, campaign)
1270
+ rows = await store.recent_llm_calls(
1271
+ stage=stage, world_id=world_id, branch_ids=branch_ids, limit=limit
1272
+ )
1273
+ finally:
1274
+ await store.close()
1275
+ if not rows:
1276
+ typer.echo("(no metered calls)")
1277
+ for r in rows:
1278
+ tokens = f"{r['tokens_in'] or '?'}→{r['tokens_out'] or '?'} tokens"
1279
+ typer.echo(
1280
+ f"{r['created_at']:%Y-%m-%d %H:%M:%S} {r['stage_tag']:<10} {r['model'] or '-'} "
1281
+ f"{tokens} {r['latency_ms']}ms"
1282
+ )
1283
+ if r["response"] is not None:
1284
+ typer.echo(f" {r['response']}")
1285
+
1286
+ _run_async(_run)
1287
+
1288
+
1195
1289
  @ruleset_app.command("list")
1196
1290
  def ruleset_list() -> None:
1197
1291
  """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