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.
- {uro_cli-0.6.0 → uro_cli-0.8.0}/PKG-INFO +3 -3
- {uro_cli-0.6.0 → uro_cli-0.8.0}/pyproject.toml +3 -3
- uro_cli-0.8.0/tests/test_connect.py +133 -0
- {uro_cli-0.6.0 → uro_cli-0.8.0}/uro_cli/main.py +162 -12
- {uro_cli-0.6.0 → uro_cli-0.8.0}/.gitignore +0 -0
- {uro_cli-0.6.0 → uro_cli-0.8.0}/LICENSE +0 -0
- {uro_cli-0.6.0 → uro_cli-0.8.0}/README.md +0 -0
- {uro_cli-0.6.0 → uro_cli-0.8.0}/tests/test_cli_scaffold.py +0 -0
- {uro_cli-0.6.0 → uro_cli-0.8.0}/tests/test_registry_router.py +0 -0
- {uro_cli-0.6.0 → uro_cli-0.8.0}/tests/test_wiring.py +0 -0
- {uro_cli-0.6.0 → uro_cli-0.8.0}/uro_cli/__init__.py +0 -0
- {uro_cli-0.6.0 → uro_cli-0.8.0}/uro_cli/py.typed +0 -0
- {uro_cli-0.6.0 → uro_cli-0.8.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.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.
|
|
16
|
-
Requires-Dist: uro-server==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.
|
|
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.
|
|
17
|
-
"uro-server==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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1179
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|