uro-cli 0.7.0__tar.gz → 0.9.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.
- {uro_cli-0.7.0 → uro_cli-0.9.0}/PKG-INFO +3 -3
- {uro_cli-0.7.0 → uro_cli-0.9.0}/pyproject.toml +3 -3
- uro_cli-0.9.0/tests/test_connect.py +133 -0
- {uro_cli-0.7.0 → uro_cli-0.9.0}/uro_cli/main.py +64 -8
- {uro_cli-0.7.0 → uro_cli-0.9.0}/.gitignore +0 -0
- {uro_cli-0.7.0 → uro_cli-0.9.0}/LICENSE +0 -0
- {uro_cli-0.7.0 → uro_cli-0.9.0}/README.md +0 -0
- {uro_cli-0.7.0 → uro_cli-0.9.0}/tests/test_cli_scaffold.py +0 -0
- {uro_cli-0.7.0 → uro_cli-0.9.0}/tests/test_registry_router.py +0 -0
- {uro_cli-0.7.0 → uro_cli-0.9.0}/tests/test_wiring.py +0 -0
- {uro_cli-0.7.0 → uro_cli-0.9.0}/uro_cli/__init__.py +0 -0
- {uro_cli-0.7.0 → uro_cli-0.9.0}/uro_cli/py.typed +0 -0
- {uro_cli-0.7.0 → uro_cli-0.9.0}/uro_cli/wiring.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: uro-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.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.
|
|
16
|
-
Requires-Dist: uro-server==0.
|
|
15
|
+
Requires-Dist: uro-core[llm,postgres]==0.9.0
|
|
16
|
+
Requires-Dist: uro-server==0.9.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.
|
|
3
|
+
version = "0.9.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.
|
|
17
|
-
"uro-server==0.
|
|
16
|
+
"uro-core[postgres,llm]==0.9.0",
|
|
17
|
+
"uro-server==0.9.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
|
|
@@ -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,
|
|
@@ -653,20 +653,24 @@ def serve(
|
|
|
653
653
|
from uro_core.adapters.nats import NatsCommitFeed
|
|
654
654
|
|
|
655
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
|
-
)
|
|
659
656
|
|
|
660
|
-
@fastapi_app.on_event("startup")
|
|
661
657
|
async def _startup() -> None:
|
|
662
658
|
await connect_store(store)
|
|
663
659
|
if deps.hydrate_tokens is not None: # load durable runtime tokens (D-39) — AFTER connect
|
|
664
660
|
await deps.hydrate_tokens()
|
|
665
661
|
|
|
666
|
-
@fastapi_app.on_event("shutdown")
|
|
667
662
|
async def _shutdown() -> None:
|
|
668
663
|
await store.close()
|
|
669
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
|
+
|
|
670
674
|
# (The DB preflight is folded into _resolve_router above — its connect_store gives the same
|
|
671
675
|
# clean docker-first hint on a down DB, and the pool it opens is closed before uvicorn starts;
|
|
672
676
|
# the startup hook re-opens one on uvicorn's own loop.)
|
|
@@ -697,6 +701,7 @@ def connect(
|
|
|
697
701
|
the campaign stream in live."""
|
|
698
702
|
import asyncio
|
|
699
703
|
import json
|
|
704
|
+
import uuid
|
|
700
705
|
|
|
701
706
|
import websockets
|
|
702
707
|
|
|
@@ -707,11 +712,44 @@ def connect(
|
|
|
707
712
|
try:
|
|
708
713
|
async with websockets.connect(uri) as ws:
|
|
709
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()
|
|
710
744
|
|
|
711
745
|
async def receive() -> None:
|
|
746
|
+
nonlocal echoes
|
|
712
747
|
async for raw in ws:
|
|
713
748
|
msg = json.loads(raw)
|
|
714
749
|
kind = msg.get("type")
|
|
750
|
+
echoes = echoes or "intent_id" in msg
|
|
751
|
+
if kind in settles:
|
|
752
|
+
settle(msg)
|
|
715
753
|
if kind == "narration_chunk":
|
|
716
754
|
typer.echo(msg["text"], nl=False)
|
|
717
755
|
elif kind == "beat_committed":
|
|
@@ -740,6 +778,10 @@ def connect(
|
|
|
740
778
|
)
|
|
741
779
|
elif kind == "beat_failed":
|
|
742
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
|
+
)
|
|
743
785
|
elif kind == "commit": # made elsewhere: another server, the CLI (D-67)
|
|
744
786
|
for beat in msg.get("beats") or []:
|
|
745
787
|
typer.echo(f"\n[{beat['participant_id']}] {beat['narration']}")
|
|
@@ -759,7 +801,20 @@ def connect(
|
|
|
759
801
|
elif intent.startswith("/vote "): # non-canon consensus vote (D-38)
|
|
760
802
|
await ws.send(json.dumps({"type": "vote", "choice": intent[6:]}))
|
|
761
803
|
elif intent:
|
|
762
|
-
|
|
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
|
|
763
818
|
receiver.cancel()
|
|
764
819
|
except OSError as exc:
|
|
765
820
|
typer.echo(f"could not connect to {server}: {exc} (is `uro serve` running?)", err=True)
|
|
@@ -964,7 +1019,7 @@ def branch_fork(
|
|
|
964
1019
|
pick = registry.resolve_for_branch(store, branch.branch_id)
|
|
965
1020
|
ruleset = await _time_skip_ruleset(pick)
|
|
966
1021
|
engine = Engine(store, build_router("stub", None), ruleset=ruleset)
|
|
967
|
-
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)
|
|
968
1023
|
finally:
|
|
969
1024
|
await store.close()
|
|
970
1025
|
forked = branch.forked_from if branch.forked_from else "-"
|
|
@@ -1279,6 +1334,7 @@ def calls(
|
|
|
1279
1334
|
typer.echo(
|
|
1280
1335
|
f"{r['created_at']:%Y-%m-%d %H:%M:%S} {r['stage_tag']:<10} {r['model'] or '-'} "
|
|
1281
1336
|
f"{tokens} {r['latency_ms']}ms"
|
|
1337
|
+
+ (f" FAILED: {r['error']}" if r.get("error") else "")
|
|
1282
1338
|
)
|
|
1283
1339
|
if r["response"] is not None:
|
|
1284
1340
|
typer.echo(f" {r['response']}")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|