uro-cli 0.2.0__py3-none-any.whl
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/__init__.py +8 -0
- uro_cli/main.py +1090 -0
- uro_cli/py.typed +0 -0
- uro_cli/wiring.py +197 -0
- uro_cli-0.2.0.dist-info/METADATA +56 -0
- uro_cli-0.2.0.dist-info/RECORD +9 -0
- uro_cli-0.2.0.dist-info/WHEEL +4 -0
- uro_cli-0.2.0.dist-info/entry_points.txt +2 -0
- uro_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
uro_cli/main.py
ADDED
|
@@ -0,0 +1,1090 @@
|
|
|
1
|
+
"""Entry point for the `uro` command. Command surface per docs/08-api-and-sessions.md.
|
|
2
|
+
|
|
3
|
+
Phase 0 subset: version, db migrate, world new, play.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import sys
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from uro_core.adapters.postgres.store import PostgresEventStore
|
|
14
|
+
from uro_core.pipeline.engine import Engine
|
|
15
|
+
from uro_core.rulesets.base import CharSpec
|
|
16
|
+
from uro_core.rulesets.rng import Rng
|
|
17
|
+
from uro_core.timeline.models import World
|
|
18
|
+
|
|
19
|
+
from uro_cli.wiring import (
|
|
20
|
+
DatabaseUnavailable,
|
|
21
|
+
build_router,
|
|
22
|
+
build_ruleset,
|
|
23
|
+
build_store,
|
|
24
|
+
connect_store,
|
|
25
|
+
parse_role_models,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
app = typer.Typer(no_args_is_help=True, help="Uro Engine — reference client.")
|
|
29
|
+
db_app = typer.Typer(no_args_is_help=True, help="Database management.")
|
|
30
|
+
world_app = typer.Typer(no_args_is_help=True, help="World and campaign management.")
|
|
31
|
+
branch_app = typer.Typer(no_args_is_help=True, help="Branch and timeline management (docs/03).")
|
|
32
|
+
campaign_app = typer.Typer(no_args_is_help=True, help="Campaign lifecycle over branches.")
|
|
33
|
+
codex_app = typer.Typer(
|
|
34
|
+
no_args_is_help=True, help="Player codex — out-of-world notes that survive a fork (docs/18 B8)."
|
|
35
|
+
)
|
|
36
|
+
token_app = typer.Typer(
|
|
37
|
+
no_args_is_help=True, help="Runtime session tokens against a running server (docs/18 B10)."
|
|
38
|
+
)
|
|
39
|
+
app.add_typer(db_app, name="db")
|
|
40
|
+
app.add_typer(world_app, name="world")
|
|
41
|
+
app.add_typer(branch_app, name="branch")
|
|
42
|
+
app.add_typer(campaign_app, name="campaign")
|
|
43
|
+
app.add_typer(codex_app, name="codex")
|
|
44
|
+
app.add_typer(token_app, name="token")
|
|
45
|
+
|
|
46
|
+
PARTICIPANT = "player-1" # Phase 0 is single-player; participants arrive in Phase 5.
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _run_async(coro_factory) -> None: # type: ignore[no-untyped-def]
|
|
50
|
+
"""Run an async command, turning config/credential/lookup errors into a clean message."""
|
|
51
|
+
try:
|
|
52
|
+
asyncio.run(coro_factory())
|
|
53
|
+
except (RuntimeError, ValueError, KeyError) as exc:
|
|
54
|
+
typer.echo(f"error: {exc}", err=True)
|
|
55
|
+
raise typer.Exit(1) from exc
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def _world_or_exit(store: PostgresEventStore, ident: str) -> World:
|
|
59
|
+
"""Resolve a world by name (preferred) or id, or exit 1 with a clean message."""
|
|
60
|
+
world = await store.get_world_by_name(ident) or await store.get_world(ident)
|
|
61
|
+
if world is None:
|
|
62
|
+
typer.echo(f"no such world: {ident}", err=True)
|
|
63
|
+
raise typer.Exit(1)
|
|
64
|
+
return world
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _build_pc_sheet(ruleset_id: str = "", version: str = "") -> tuple[dict[str, Any], str]:
|
|
68
|
+
"""A default character sheet from the world's bound ruleset, so every PC can be checked
|
|
69
|
+
(docs/06). `ruleset_id` selects it via the registry — a PbtA world mints a PbtA sheet."""
|
|
70
|
+
ruleset = build_ruleset(ruleset_id, version)
|
|
71
|
+
return ruleset.new_character(CharSpec(), Rng(0)), ruleset.id
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.callback()
|
|
75
|
+
def main() -> None:
|
|
76
|
+
"""Uro Engine — play, dry-run, and dev tools against the engine."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.command()
|
|
80
|
+
def version() -> None:
|
|
81
|
+
"""Print engine and client versions."""
|
|
82
|
+
import uro_core
|
|
83
|
+
|
|
84
|
+
import uro_cli
|
|
85
|
+
|
|
86
|
+
typer.echo(f"uro-cli {uro_cli.__version__} / uro-core {uro_core.__version__}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@db_app.command("migrate")
|
|
90
|
+
def db_migrate() -> None:
|
|
91
|
+
"""Apply pending database migrations."""
|
|
92
|
+
|
|
93
|
+
async def _run() -> None:
|
|
94
|
+
store = build_store()
|
|
95
|
+
await connect_store(store)
|
|
96
|
+
try:
|
|
97
|
+
applied = await store.migrate()
|
|
98
|
+
finally:
|
|
99
|
+
await store.close()
|
|
100
|
+
if applied:
|
|
101
|
+
typer.echo(f"applied: {', '.join(applied)}")
|
|
102
|
+
else:
|
|
103
|
+
typer.echo("already up to date")
|
|
104
|
+
|
|
105
|
+
_run_async(_run)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@world_app.command("new")
|
|
109
|
+
def world_new(name: str) -> None:
|
|
110
|
+
"""Create a world (+ its main branch) and a ready-to-play campaign with a default,
|
|
111
|
+
sheeted PC. Prints the campaign id."""
|
|
112
|
+
|
|
113
|
+
async def _run() -> None:
|
|
114
|
+
store = build_store()
|
|
115
|
+
await connect_store(store)
|
|
116
|
+
try:
|
|
117
|
+
world = await store.create_world(name)
|
|
118
|
+
sheet, ruleset_id = _build_pc_sheet()
|
|
119
|
+
campaign = await store.start_campaign(
|
|
120
|
+
world.world_id,
|
|
121
|
+
world.main_branch_id,
|
|
122
|
+
participant_id=PARTICIPANT,
|
|
123
|
+
new_pc_name="Adventurer",
|
|
124
|
+
pc_sheet=sheet,
|
|
125
|
+
starting_items=["a traveler's knife"],
|
|
126
|
+
ruleset_id=ruleset_id,
|
|
127
|
+
)
|
|
128
|
+
finally:
|
|
129
|
+
await store.close()
|
|
130
|
+
typer.echo(f"world: {world.world_id} ({name})")
|
|
131
|
+
typer.echo(f"campaign: {campaign.campaign_id} (PC: Adventurer)")
|
|
132
|
+
typer.echo(f"\nplay it: uro play {campaign.campaign_id}")
|
|
133
|
+
|
|
134
|
+
_run_async(_run)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@world_app.command("validate")
|
|
138
|
+
def world_validate(path: str) -> None:
|
|
139
|
+
"""Parse a world pack and report its sufficiency (docs/09) — the creator loop, no import."""
|
|
140
|
+
from uro_core.errors import PackError
|
|
141
|
+
from uro_core.rulesets import registry
|
|
142
|
+
from uro_core.worldpack.parse import parse_pack
|
|
143
|
+
from uro_core.worldpack.sufficiency import check_sufficiency
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
pack = parse_pack(path)
|
|
147
|
+
except PackError as exc:
|
|
148
|
+
typer.echo(f"error: {exc}", err=True)
|
|
149
|
+
raise typer.Exit(1) from exc
|
|
150
|
+
report = check_sufficiency(pack)
|
|
151
|
+
typer.echo(
|
|
152
|
+
f"world: {pack.manifest.name} "
|
|
153
|
+
f"({len(pack.places)} places, {len(pack.actors)} actors, "
|
|
154
|
+
f"{len(pack.factions)} factions, {len(pack.threads)} conflict seeds)"
|
|
155
|
+
)
|
|
156
|
+
typer.echo(f"grade: {report.grade.upper()}")
|
|
157
|
+
for d in report.dimensions:
|
|
158
|
+
typer.echo(f" {'ok ' if d.ok else 'GAP'} {d.name:<10} {d.detail}")
|
|
159
|
+
# Catch a bad ruleset id at VALIDATE, not as a raw KeyError at `campaign new` (phase-6 review).
|
|
160
|
+
rid = pack.manifest.ruleset.id
|
|
161
|
+
if rid in registry.available():
|
|
162
|
+
typer.echo(f" ok ruleset {rid}")
|
|
163
|
+
else:
|
|
164
|
+
typer.echo(f" GAP ruleset unknown ruleset {rid!r} (installed: {registry.available()})")
|
|
165
|
+
if report.grade != "runnable":
|
|
166
|
+
typer.echo("\ngaps to fix (or run backfill):")
|
|
167
|
+
for g in report.gaps:
|
|
168
|
+
typer.echo(f" - {g}")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@world_app.command("create")
|
|
172
|
+
def world_create(
|
|
173
|
+
path: str,
|
|
174
|
+
backfill: bool = typer.Option(
|
|
175
|
+
False, "--backfill", help="AI-fill gaps before import (committed, tagged ai_backfill)"
|
|
176
|
+
),
|
|
177
|
+
provider: str = typer.Option("openai", help="provider for --backfill"),
|
|
178
|
+
model: str = typer.Option(None, help="model id for the worldsmith role (with --backfill)"),
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Import a world pack (docs/09): validate, then commit the authored (and, with --backfill,
|
|
181
|
+
AI-filled) seeds as a new world."""
|
|
182
|
+
|
|
183
|
+
async def _run() -> None:
|
|
184
|
+
from uro_core.errors import PackError
|
|
185
|
+
from uro_core.rulesets import registry
|
|
186
|
+
from uro_core.worldpack.backfill import backfill_gaps
|
|
187
|
+
from uro_core.worldpack.importer import pack_to_events
|
|
188
|
+
from uro_core.worldpack.parse import parse_pack
|
|
189
|
+
from uro_core.worldpack.sufficiency import check_sufficiency
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
pack = parse_pack(path)
|
|
193
|
+
except PackError as exc:
|
|
194
|
+
typer.echo(f"error: {exc}", err=True)
|
|
195
|
+
raise typer.Exit(1) from exc
|
|
196
|
+
# Fail loudly HERE if the pack names a ruleset the build can't resolve — else the world
|
|
197
|
+
# commits fine and only blows up as a raw KeyError at `campaign new` (phase-6 review).
|
|
198
|
+
if pack.manifest.ruleset.id not in registry.available():
|
|
199
|
+
typer.echo(
|
|
200
|
+
f"error: pack declares unknown ruleset {pack.manifest.ruleset.id!r} "
|
|
201
|
+
f"(installed: {registry.available()})",
|
|
202
|
+
err=True,
|
|
203
|
+
)
|
|
204
|
+
raise typer.Exit(1)
|
|
205
|
+
report = check_sufficiency(pack)
|
|
206
|
+
added: list[str] = []
|
|
207
|
+
if backfill and report.grade != "runnable":
|
|
208
|
+
pack, added = await backfill_gaps(pack, build_router(provider, model), report=report)
|
|
209
|
+
report = check_sufficiency(pack)
|
|
210
|
+
if report.grade == "insufficient":
|
|
211
|
+
typer.echo(f"error: pack is INSUFFICIENT to run: {'; '.join(report.gaps)}", err=True)
|
|
212
|
+
raise typer.Exit(1)
|
|
213
|
+
store = build_store()
|
|
214
|
+
await connect_store(store)
|
|
215
|
+
try:
|
|
216
|
+
world = await store.create_world(
|
|
217
|
+
pack.manifest.name,
|
|
218
|
+
tone=pack.manifest.tone,
|
|
219
|
+
prompt_overrides=pack.prompts,
|
|
220
|
+
ruleset_id=pack.manifest.ruleset.id,
|
|
221
|
+
ruleset_version=pack.manifest.ruleset.version,
|
|
222
|
+
rule_pack=pack.rule_pack.model_dump() if pack.rule_pack else {},
|
|
223
|
+
extra_events=pack_to_events(pack),
|
|
224
|
+
)
|
|
225
|
+
finally:
|
|
226
|
+
await store.close()
|
|
227
|
+
typer.echo(f"world: {world.world_id} ({pack.manifest.name}, grade {report.grade})")
|
|
228
|
+
for a in added:
|
|
229
|
+
typer.echo(f" backfilled + committed: {a}")
|
|
230
|
+
typer.echo(f"seed history: uro world seed {path} --seed 42")
|
|
231
|
+
|
|
232
|
+
_run_async(_run)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@world_app.command("seed")
|
|
236
|
+
def world_seed(
|
|
237
|
+
path: str, seed: int = typer.Option(42, "--seed", help="RNG seed for History")
|
|
238
|
+
) -> None:
|
|
239
|
+
"""Run History seeding on the world imported from <path>: layer seed-dependent dynasties and
|
|
240
|
+
wars on top of the authored geography (docs/09). Same pack + a different seed → a different
|
|
241
|
+
history on identical geography."""
|
|
242
|
+
|
|
243
|
+
async def _run() -> None:
|
|
244
|
+
from uro_core.engines.history import seed_history
|
|
245
|
+
from uro_core.errors import PackError
|
|
246
|
+
from uro_core.rulesets.rng import Rng
|
|
247
|
+
from uro_core.worldpack.parse import parse_pack
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
pack = parse_pack(path)
|
|
251
|
+
except PackError as exc:
|
|
252
|
+
typer.echo(f"error: {exc}", err=True)
|
|
253
|
+
raise typer.Exit(1) from exc
|
|
254
|
+
events = seed_history(pack.manifest, Rng(seed))
|
|
255
|
+
store = build_store()
|
|
256
|
+
await connect_store(store)
|
|
257
|
+
try:
|
|
258
|
+
world = await store.get_world_by_name(pack.manifest.name)
|
|
259
|
+
if world is None:
|
|
260
|
+
typer.echo(
|
|
261
|
+
f"no imported world named {pack.manifest.name!r} — "
|
|
262
|
+
f"run `uro world create {path}` first",
|
|
263
|
+
err=True,
|
|
264
|
+
)
|
|
265
|
+
raise typer.Exit(1)
|
|
266
|
+
commit = await store.append_beat(world.main_branch_id, events)
|
|
267
|
+
finally:
|
|
268
|
+
await store.close()
|
|
269
|
+
dynasties = sum(1 for e in events if e.event_type == "FactionCreated")
|
|
270
|
+
wars = sum(
|
|
271
|
+
1
|
|
272
|
+
for e in events
|
|
273
|
+
if e.event_type == "EdgeAdded" and e.payload.get("rel_type") == "at_war_with"
|
|
274
|
+
)
|
|
275
|
+
typer.echo(
|
|
276
|
+
f"seeded {pack.manifest.name!r} with seed {seed} → commit {commit.commit_id[:8]}"
|
|
277
|
+
)
|
|
278
|
+
typer.echo(f" {dynasties} dynasties, {wars} wars (on the pack's authored geography)")
|
|
279
|
+
|
|
280
|
+
_run_async(_run)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@world_app.command("backfill")
|
|
284
|
+
def world_backfill(
|
|
285
|
+
path: str,
|
|
286
|
+
provider: str = typer.Option("openai", help="stub | local | openai | anthropic"),
|
|
287
|
+
model: str = typer.Option(None, help="model id for the worldsmith role"),
|
|
288
|
+
) -> None:
|
|
289
|
+
"""Offer to fill a thin pack's gaps with AI-generated, provenance-tagged seeds (docs/09).
|
|
290
|
+
Opt-in; prints what WOULD be added (does not rewrite the pack)."""
|
|
291
|
+
|
|
292
|
+
async def _run() -> None:
|
|
293
|
+
from uro_core.errors import PackError
|
|
294
|
+
from uro_core.worldpack.backfill import backfill_gaps
|
|
295
|
+
from uro_core.worldpack.parse import parse_pack
|
|
296
|
+
from uro_core.worldpack.sufficiency import check_sufficiency
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
pack = parse_pack(path)
|
|
300
|
+
except PackError as exc:
|
|
301
|
+
typer.echo(f"error: {exc}", err=True)
|
|
302
|
+
raise typer.Exit(1) from exc
|
|
303
|
+
before = check_sufficiency(pack)
|
|
304
|
+
if before.grade == "runnable":
|
|
305
|
+
typer.echo("pack is already runnable — nothing to backfill")
|
|
306
|
+
return
|
|
307
|
+
augmented, added = await backfill_gaps(pack, build_router(provider, model), report=before)
|
|
308
|
+
after = check_sufficiency(augmented)
|
|
309
|
+
typer.echo(f"backfill: {before.grade} → {after.grade}")
|
|
310
|
+
for a in added:
|
|
311
|
+
typer.echo(f" + {a}")
|
|
312
|
+
if not added:
|
|
313
|
+
typer.echo(" (model produced nothing usable — gaps remain)")
|
|
314
|
+
|
|
315
|
+
_run_async(_run)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@world_app.command("probe")
|
|
319
|
+
def world_probe(
|
|
320
|
+
path: str,
|
|
321
|
+
provider: str = typer.Option("openai", help="stub | local | openai | anthropic"),
|
|
322
|
+
model: str = typer.Option(None, help="model id for the bound roles"),
|
|
323
|
+
tries: int = typer.Option(3, help="structured-output attempts"),
|
|
324
|
+
) -> None:
|
|
325
|
+
"""Probe whether the bound models can deliver what the world declares (docs/04) — a
|
|
326
|
+
compatibility report, not enforcement."""
|
|
327
|
+
|
|
328
|
+
async def _run() -> None:
|
|
329
|
+
from uro_core.engines.probe import run_probes
|
|
330
|
+
from uro_core.errors import PackError
|
|
331
|
+
from uro_core.worldpack.parse import parse_pack
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
pack = parse_pack(path)
|
|
335
|
+
except PackError as exc:
|
|
336
|
+
typer.echo(f"error: {exc}", err=True)
|
|
337
|
+
raise typer.Exit(1) from exc
|
|
338
|
+
report = await run_probes(pack.manifest, build_router(provider, model), tries=tries)
|
|
339
|
+
typer.echo(f"probe report for {report.world}: {'OK' if report.ok else 'ISSUES'}")
|
|
340
|
+
for r in report.results:
|
|
341
|
+
gate = f" (gates {r.gate_for})" if r.gate_for else ""
|
|
342
|
+
typer.echo(f" [{r.status.upper():4}] {r.name}: {r.detail}{gate}")
|
|
343
|
+
|
|
344
|
+
_run_async(_run)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@world_app.command("export")
|
|
348
|
+
def world_export(
|
|
349
|
+
world: str,
|
|
350
|
+
out: str = typer.Option(..., "-o", "--out", help="output bundle path (e.g. ashfall.uwp)"),
|
|
351
|
+
) -> None:
|
|
352
|
+
"""Export a world's whole log to a portable, hash-chain-verified bundle (docs/08)."""
|
|
353
|
+
|
|
354
|
+
async def _run() -> None:
|
|
355
|
+
from pathlib import Path
|
|
356
|
+
|
|
357
|
+
store = build_store()
|
|
358
|
+
await connect_store(store)
|
|
359
|
+
try:
|
|
360
|
+
w = await _world_or_exit(store, world)
|
|
361
|
+
bundle = await store.export_world(w.world_id)
|
|
362
|
+
finally:
|
|
363
|
+
await store.close()
|
|
364
|
+
Path(out).write_text(bundle.model_dump_json(indent=2))
|
|
365
|
+
typer.echo(f"exported {w.name!r}: {len(bundle.commits)} commits → {out}")
|
|
366
|
+
|
|
367
|
+
_run_async(_run)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@world_app.command("import")
|
|
371
|
+
def world_import(path: str) -> None:
|
|
372
|
+
"""Import a world bundle (docs/08): verify its hash chain, then instantiate it as a fresh
|
|
373
|
+
world. A tampered bundle is rejected before anything is written."""
|
|
374
|
+
|
|
375
|
+
async def _run() -> None:
|
|
376
|
+
from pathlib import Path
|
|
377
|
+
|
|
378
|
+
from uro_core.errors import ExportError
|
|
379
|
+
from uro_core.export import WorldBundle
|
|
380
|
+
|
|
381
|
+
bundle = WorldBundle.model_validate_json(Path(path).read_text())
|
|
382
|
+
store = build_store()
|
|
383
|
+
await connect_store(store)
|
|
384
|
+
try:
|
|
385
|
+
world = await store.import_world(bundle)
|
|
386
|
+
except ExportError as exc:
|
|
387
|
+
typer.echo(f"error: bundle failed verification — {exc}", err=True)
|
|
388
|
+
raise typer.Exit(1) from exc
|
|
389
|
+
finally:
|
|
390
|
+
await store.close()
|
|
391
|
+
typer.echo(f"imported {world.name!r} (chain verified) → world {world.world_id}")
|
|
392
|
+
typer.echo(f"continue it: uro campaign new {world.name} --branch main")
|
|
393
|
+
|
|
394
|
+
_run_async(_run)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@app.command()
|
|
398
|
+
def play(
|
|
399
|
+
campaign_id: str,
|
|
400
|
+
provider: str = typer.Option("stub", help="stub | local | openai | anthropic"),
|
|
401
|
+
model: str = typer.Option(None, help="model id for local/openai/anthropic providers"),
|
|
402
|
+
bare: bool = typer.Option(
|
|
403
|
+
False,
|
|
404
|
+
help="ablation (T1): raw-transcript GM, no state/recall/extraction. Use a FRESH "
|
|
405
|
+
"campaign — mixing bare and full beats on one corrupts the A/B comparison.",
|
|
406
|
+
),
|
|
407
|
+
no_mechanics: bool = typer.Option(
|
|
408
|
+
False,
|
|
409
|
+
"--no-mechanics",
|
|
410
|
+
help="narrative-only: full recall + extraction + memory, but NO ruleset/planner "
|
|
411
|
+
"(the thesis test — recall without the mechanics confound). Distinct from --bare.",
|
|
412
|
+
),
|
|
413
|
+
role_model: list[str] = typer.Option( # noqa: B008 (typer DI-style default, like every option here)
|
|
414
|
+
None,
|
|
415
|
+
"--role-model",
|
|
416
|
+
help="per-role model override, repeatable: role=spec (spec = 'openai:gpt-4o' or a bare "
|
|
417
|
+
"'gpt-4o' on the default provider). Wins over uro.toml. E.g. --role-model planner=gpt-4o "
|
|
418
|
+
"keeps a cheap default for extraction but a strong planner (docs/04).",
|
|
419
|
+
),
|
|
420
|
+
) -> None:
|
|
421
|
+
"""Interactive play loop. Type an action; '/quit' to leave. State persists to Postgres."""
|
|
422
|
+
role_models = parse_role_models(role_model)
|
|
423
|
+
|
|
424
|
+
async def _run() -> None:
|
|
425
|
+
store = build_store()
|
|
426
|
+
await connect_store(store)
|
|
427
|
+
try:
|
|
428
|
+
campaign = await store.get_campaign(campaign_id)
|
|
429
|
+
if campaign is None:
|
|
430
|
+
typer.echo(f"no such campaign: {campaign_id}", err=True)
|
|
431
|
+
raise typer.Exit(1)
|
|
432
|
+
# No ruleset in bare (ablation) OR --no-mechanics (narrative-only) mode — the
|
|
433
|
+
# planner/gate are exactly what bare ablates and what no-mechanics skips.
|
|
434
|
+
engine = Engine(
|
|
435
|
+
store,
|
|
436
|
+
build_router(provider, model, role_models),
|
|
437
|
+
# Rebind the campaign's OWN ruleset (D-30) — a PbtA campaign plays under uro_pbta.
|
|
438
|
+
ruleset=None
|
|
439
|
+
if (bare or no_mechanics)
|
|
440
|
+
else build_ruleset(campaign.ruleset_id, campaign.ruleset_version),
|
|
441
|
+
bare=bare,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
history = await store.recent_beats(campaign.branch_id, 3)
|
|
445
|
+
if history:
|
|
446
|
+
typer.echo("— resuming; recent beats —")
|
|
447
|
+
for beat in history:
|
|
448
|
+
typer.echo(f" > {beat.intent_text}")
|
|
449
|
+
typer.echo(f" {beat.narration}")
|
|
450
|
+
typer.echo("—")
|
|
451
|
+
|
|
452
|
+
while True:
|
|
453
|
+
try:
|
|
454
|
+
intent = input("> ").strip()
|
|
455
|
+
except (EOFError, KeyboardInterrupt):
|
|
456
|
+
typer.echo("")
|
|
457
|
+
break
|
|
458
|
+
if intent in ("/quit", "/exit"):
|
|
459
|
+
break
|
|
460
|
+
if not intent:
|
|
461
|
+
continue
|
|
462
|
+
try:
|
|
463
|
+
async for chunk in engine.run_beat_stream(campaign, PARTICIPANT, intent):
|
|
464
|
+
sys.stdout.write(chunk)
|
|
465
|
+
sys.stdout.flush()
|
|
466
|
+
sys.stdout.write("\n")
|
|
467
|
+
except Exception as exc:
|
|
468
|
+
sys.stdout.write("\n")
|
|
469
|
+
typer.echo(f"beat failed ({exc}); nothing was saved — try again.", err=True)
|
|
470
|
+
finally:
|
|
471
|
+
await store.close()
|
|
472
|
+
|
|
473
|
+
_run_async(_run)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@app.command()
|
|
477
|
+
def serve(
|
|
478
|
+
host: str = typer.Option("127.0.0.1", help="bind address"),
|
|
479
|
+
port: int = typer.Option(8000, help="bind port"),
|
|
480
|
+
token: list[str] = typer.Option( # noqa: B008 (typer DI-style default, like every option here)
|
|
481
|
+
None,
|
|
482
|
+
"--token",
|
|
483
|
+
help="bearer token → participant (repeatable). `TOK=participant` names it explicitly (e.g. "
|
|
484
|
+
"--token tok-a=alice); a bare `TOK` maps positionally to player-1..N (docs/08, gap G-16)",
|
|
485
|
+
),
|
|
486
|
+
admin_token: list[str] = typer.Option( # noqa: B008
|
|
487
|
+
None,
|
|
488
|
+
"--admin-token",
|
|
489
|
+
help="OPERATOR token TOK[=name] (repeatable, D-39): may seat/mint/revoke for ANOTHER "
|
|
490
|
+
"participant (e.g. a GM seating a party). A plain --token peer may act only for itself.",
|
|
491
|
+
),
|
|
492
|
+
provider: str = typer.Option("stub", help="stub | local | openai | anthropic"),
|
|
493
|
+
model: str = typer.Option(None, help="model id for the provider"),
|
|
494
|
+
ruleset: str = typer.Option(
|
|
495
|
+
"", help="ruleset id for this server process (default: uro-basic via the registry)"
|
|
496
|
+
),
|
|
497
|
+
arbiter_kind: str = typer.Option(
|
|
498
|
+
None,
|
|
499
|
+
"--arbiter",
|
|
500
|
+
help="turn shape (D-38): party (round-robin) | proposal (propose-then-act) | vote "
|
|
501
|
+
"(consensus). UNSET = auto (solo for 1 token, party for >1). Setting it EXPLICITLY binds "
|
|
502
|
+
"that shape even for a 1-token launch, so a runtime-added player still gets turns (D-39).",
|
|
503
|
+
),
|
|
504
|
+
) -> None:
|
|
505
|
+
"""Run the Uro server (docs/08): a thin FastAPI shell over the engine. Each --token maps to
|
|
506
|
+
a participant; with no --token, a single 'local' token binds player-1 (the solo dev loop).
|
|
507
|
+
|
|
508
|
+
NOTE (PoC limitation): the server binds ONE ruleset per process (--ruleset), since its single
|
|
509
|
+
Engine is shared across campaigns. Per-campaign rebinding (as the `play`/`dry-run` CLI paths
|
|
510
|
+
do from campaign.ruleset_id) needs the Engine to resolve the ruleset per beat — deferred."""
|
|
511
|
+
import uvicorn
|
|
512
|
+
from uro_core.pipeline.engine import Engine
|
|
513
|
+
from uro_core.session import PartyArbiter, ProposalWindowArbiter, TurnArbiter, VoteArbiter
|
|
514
|
+
from uro_server.app import create_app, engine_deps
|
|
515
|
+
|
|
516
|
+
toks = token or ["local"]
|
|
517
|
+
# `TOK=participant` names the participant explicitly (gap G-16 — token order was load-bearing
|
|
518
|
+
# config); a bare `TOK` falls back to positional player-1..N.
|
|
519
|
+
tokens: dict[str, str] = {}
|
|
520
|
+
for i, t in enumerate(toks):
|
|
521
|
+
tok, sep, name = t.partition("=")
|
|
522
|
+
tokens[tok] = name if sep else f"player-{i + 1}"
|
|
523
|
+
# Operator tokens (D-39): resolvable participants like any --token, PLUS flagged admin so they
|
|
524
|
+
# may act for others. A plain --token peer is NOT admin (else any launch peer could impersonate
|
|
525
|
+
# any other — D-39 review). None given → no operator; a new runtime player is seated by an
|
|
526
|
+
# operator or via the direct-DB `uro campaign join`.
|
|
527
|
+
admin_tokens: set[str] = set()
|
|
528
|
+
for j, t in enumerate(admin_token or []):
|
|
529
|
+
tok, sep, name = t.partition("=")
|
|
530
|
+
tokens[tok] = name if sep else f"operator-{j + 1}"
|
|
531
|
+
admin_tokens.add(tok)
|
|
532
|
+
store = build_store()
|
|
533
|
+
engine = Engine(store, build_router(provider, model), ruleset=build_ruleset(ruleset))
|
|
534
|
+
# Pick the turn shape (OQ-7, D-31/D-38/D-39). UNSET (--arbiter not given) is AUTO: solo for one
|
|
535
|
+
# token (the dev loop → SoloArbiter), party for >1. Set EXPLICITLY, the shape binds regardless
|
|
536
|
+
# of launch token count, so a player minted+joined at RUNTIME still gets real turns (D-39)
|
|
537
|
+
# instead of SoloArbiter's always-ADMITTED. Seat each participant's PC via `uro campaign join`.
|
|
538
|
+
shapes: dict[str, type[TurnArbiter]] = {
|
|
539
|
+
"party": PartyArbiter,
|
|
540
|
+
"proposal": ProposalWindowArbiter,
|
|
541
|
+
"vote": VoteArbiter,
|
|
542
|
+
}
|
|
543
|
+
if arbiter_kind is None:
|
|
544
|
+
arbiter: TurnArbiter | None = PartyArbiter() if len(tokens) > 1 else None
|
|
545
|
+
elif arbiter_kind not in shapes:
|
|
546
|
+
raise typer.BadParameter(
|
|
547
|
+
f"--arbiter must be one of {', '.join(shapes)} (got {arbiter_kind!r})"
|
|
548
|
+
)
|
|
549
|
+
else:
|
|
550
|
+
arbiter = shapes[arbiter_kind]()
|
|
551
|
+
deps = engine_deps(store, engine, tokens, admin_tokens)
|
|
552
|
+
fastapi_app = create_app(deps, arbiter=arbiter)
|
|
553
|
+
|
|
554
|
+
@fastapi_app.on_event("startup")
|
|
555
|
+
async def _startup() -> None:
|
|
556
|
+
await connect_store(store)
|
|
557
|
+
if deps.hydrate_tokens is not None: # load durable runtime tokens (D-39) — AFTER connect
|
|
558
|
+
await deps.hydrate_tokens()
|
|
559
|
+
|
|
560
|
+
@fastapi_app.on_event("shutdown")
|
|
561
|
+
async def _shutdown() -> None:
|
|
562
|
+
await store.close()
|
|
563
|
+
|
|
564
|
+
# Preflight the DB here so a down database gets the same clean docker-first hint every other
|
|
565
|
+
# command gives (via connect_store), instead of a uvicorn "Application startup failed"
|
|
566
|
+
# traceback. The live pool is (re)opened by the startup hook on uvicorn's own event loop — an
|
|
567
|
+
# asyncpg pool is bound to the loop that created it, so this throwaway probe is closed at once.
|
|
568
|
+
async def _preflight() -> None:
|
|
569
|
+
await connect_store(store)
|
|
570
|
+
await store.close()
|
|
571
|
+
|
|
572
|
+
try:
|
|
573
|
+
asyncio.run(_preflight())
|
|
574
|
+
except DatabaseUnavailable as exc:
|
|
575
|
+
typer.echo(f"error: {exc}", err=True)
|
|
576
|
+
raise typer.Exit(1) from exc
|
|
577
|
+
|
|
578
|
+
shape = type(arbiter).__name__ if arbiter is not None else "solo"
|
|
579
|
+
typer.echo(
|
|
580
|
+
f"uro server on ws://{host}:{port} "
|
|
581
|
+
f"({len(tokens)} token(s), provider={provider}, arbiter={shape})"
|
|
582
|
+
)
|
|
583
|
+
for t, p in tokens.items():
|
|
584
|
+
typer.echo(f" token {t!r} → {p}")
|
|
585
|
+
uvicorn.run(fastapi_app, host=host, port=port, log_level="warning")
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
@app.command()
|
|
589
|
+
def connect(
|
|
590
|
+
campaign_id: str,
|
|
591
|
+
server: str = typer.Option("http://127.0.0.1:8000", help="server base URL"),
|
|
592
|
+
token: str = typer.Option("local", help="bearer token (maps to a participant server-side)"),
|
|
593
|
+
) -> None:
|
|
594
|
+
"""HTTP-client play mode (docs/08): connect to a running `uro serve` over WebSocket. Type an
|
|
595
|
+
action to take a beat; '/say <text>' for out-of-world table-talk and '/vote <choice>' to vote
|
|
596
|
+
(D-38, both non-canon — they never commit); '/quit' to leave. Beats from every participant on
|
|
597
|
+
the campaign stream in live."""
|
|
598
|
+
import asyncio
|
|
599
|
+
import json
|
|
600
|
+
|
|
601
|
+
import websockets
|
|
602
|
+
|
|
603
|
+
ws_base = server.replace("http://", "ws://").replace("https://", "wss://").rstrip("/")
|
|
604
|
+
uri = f"{ws_base}/campaigns/{campaign_id}/play?token={token}"
|
|
605
|
+
|
|
606
|
+
async def _client() -> None:
|
|
607
|
+
try:
|
|
608
|
+
async with websockets.connect(uri) as ws:
|
|
609
|
+
typer.echo(f"connected to {campaign_id} as {token!r}. Type an action; /quit.\n")
|
|
610
|
+
|
|
611
|
+
async def receive() -> None:
|
|
612
|
+
async for raw in ws:
|
|
613
|
+
msg = json.loads(raw)
|
|
614
|
+
kind = msg.get("type")
|
|
615
|
+
if kind == "narration_chunk":
|
|
616
|
+
typer.echo(msg["text"], nl=False)
|
|
617
|
+
elif kind == "beat_committed":
|
|
618
|
+
typer.echo(f"\n ✓ [{msg['participant_id']}] {msg['intent']!r}\n")
|
|
619
|
+
elif kind == "not_your_turn":
|
|
620
|
+
typer.echo(" · not your turn — another player holds it (round-robin)")
|
|
621
|
+
elif kind == "proposal_opened":
|
|
622
|
+
typer.echo(
|
|
623
|
+
f" · [{msg['participant_id']}] proposes: {msg['text']!r} "
|
|
624
|
+
"— the turn-holder can enact it"
|
|
625
|
+
)
|
|
626
|
+
elif kind == "table_talk":
|
|
627
|
+
typer.echo(f" · [{msg['participant_id']}] says: {msg['text']}")
|
|
628
|
+
elif kind == "vote_tally":
|
|
629
|
+
typer.echo(
|
|
630
|
+
f" · [{msg['participant_id']}] voted {msg['choice']!r} "
|
|
631
|
+
f"— tally {msg['tally']}"
|
|
632
|
+
)
|
|
633
|
+
elif kind == "vote_decided":
|
|
634
|
+
typer.echo(
|
|
635
|
+
f" ✓ vote decided: {msg['choice']!r} — enact it on your turn"
|
|
636
|
+
)
|
|
637
|
+
elif kind == "vote_unsupported":
|
|
638
|
+
typer.echo(
|
|
639
|
+
" · voting isn't enabled here (server needs --arbiter vote)"
|
|
640
|
+
)
|
|
641
|
+
elif kind == "beat_failed":
|
|
642
|
+
typer.echo(f"\n ✗ beat failed: {msg['error']}")
|
|
643
|
+
elif kind in ("participant_joined", "participant_left"):
|
|
644
|
+
typer.echo(f" · {msg['participant_id']} {kind.split('_')[1]}")
|
|
645
|
+
|
|
646
|
+
receiver = asyncio.create_task(receive())
|
|
647
|
+
loop = asyncio.get_event_loop()
|
|
648
|
+
while True:
|
|
649
|
+
intent = (await loop.run_in_executor(None, input, "> ")).strip()
|
|
650
|
+
if intent in ("/quit", "/exit"):
|
|
651
|
+
break
|
|
652
|
+
if intent.startswith("/say "): # non-canon table-talk (D-38)
|
|
653
|
+
await ws.send(json.dumps({"type": "table_talk", "text": intent[5:]}))
|
|
654
|
+
elif intent.startswith("/vote "): # non-canon consensus vote (D-38)
|
|
655
|
+
await ws.send(json.dumps({"type": "vote", "choice": intent[6:]}))
|
|
656
|
+
elif intent:
|
|
657
|
+
await ws.send(json.dumps({"type": "intent", "text": intent}))
|
|
658
|
+
receiver.cancel()
|
|
659
|
+
except OSError as exc:
|
|
660
|
+
typer.echo(f"could not connect to {server}: {exc} (is `uro serve` running?)", err=True)
|
|
661
|
+
raise typer.Exit(1) from exc
|
|
662
|
+
|
|
663
|
+
asyncio.run(_client())
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _server_post(server: str, path: str, token: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
667
|
+
"""POST JSON to a running `uro serve` (stdlib urllib — no extra dep), authed by ?token."""
|
|
668
|
+
import json
|
|
669
|
+
import urllib.error
|
|
670
|
+
import urllib.request
|
|
671
|
+
|
|
672
|
+
url = f"{server.rstrip('/')}{path}?token={token}"
|
|
673
|
+
req = urllib.request.Request(
|
|
674
|
+
url, data=json.dumps(body).encode(), headers={"content-type": "application/json"}
|
|
675
|
+
)
|
|
676
|
+
try:
|
|
677
|
+
with urllib.request.urlopen(req) as resp:
|
|
678
|
+
return dict(json.loads(resp.read()))
|
|
679
|
+
except urllib.error.HTTPError as exc:
|
|
680
|
+
typer.echo(f"server said {exc.code}: {exc.read().decode()[:200]}", err=True)
|
|
681
|
+
raise typer.Exit(1) from exc
|
|
682
|
+
except OSError as exc:
|
|
683
|
+
typer.echo(f"could not reach {server}: {exc} (is `uro serve` running?)", err=True)
|
|
684
|
+
raise typer.Exit(1) from exc
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
@token_app.command("mint")
|
|
688
|
+
def token_mint(
|
|
689
|
+
campaign_id: str,
|
|
690
|
+
participant: str = typer.Option(
|
|
691
|
+
..., help="participant to mint for (yourself, or anyone with an operator token)"
|
|
692
|
+
),
|
|
693
|
+
server: str = typer.Option("http://127.0.0.1:8000", help="server base URL"),
|
|
694
|
+
token: str = typer.Option(..., help="your bearer token (self-service) or an operator --token"),
|
|
695
|
+
) -> None:
|
|
696
|
+
"""Mint a durable session token for an already-seated participant (docs/18 B10, D-39). Self-or-
|
|
697
|
+
operator scoped; the participant must have a PC on the campaign (`uro campaign join` first)."""
|
|
698
|
+
out = _server_post(
|
|
699
|
+
server, f"/campaigns/{campaign_id}/tokens", token, {"participant": participant}
|
|
700
|
+
)
|
|
701
|
+
typer.echo(out.get("token", out))
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
@token_app.command("revoke")
|
|
705
|
+
def token_revoke(
|
|
706
|
+
campaign_id: str,
|
|
707
|
+
target: str = typer.Option(..., "--target", help="the token to revoke"),
|
|
708
|
+
server: str = typer.Option("http://127.0.0.1:8000", help="server base URL"),
|
|
709
|
+
token: str = typer.Option(..., help="your bearer token (own token) or an operator --token"),
|
|
710
|
+
) -> None:
|
|
711
|
+
"""Revoke a session token (docs/18 B10, D-39). Self-or-operator scoped. Blocks a new connect;
|
|
712
|
+
a live socket survives until it disconnects (a named residual)."""
|
|
713
|
+
out = _server_post(server, f"/campaigns/{campaign_id}/tokens/revoke", token, {"token": target})
|
|
714
|
+
typer.echo("revoked" if out.get("revoked") else "no live token matched (already revoked?)")
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
@branch_app.command("list")
|
|
718
|
+
def branch_list(world: str) -> None:
|
|
719
|
+
"""List a world's branches (with head depth) and its markers."""
|
|
720
|
+
|
|
721
|
+
async def _run() -> None:
|
|
722
|
+
store = build_store()
|
|
723
|
+
await connect_store(store)
|
|
724
|
+
try:
|
|
725
|
+
w = await _world_or_exit(store, world)
|
|
726
|
+
branches = await store.list_branches(w.world_id)
|
|
727
|
+
days = await store.current_world_time_batch([b.branch_id for b in branches])
|
|
728
|
+
markers = await store.list_markers(w.world_id)
|
|
729
|
+
finally:
|
|
730
|
+
await store.close()
|
|
731
|
+
typer.echo(f"world: {w.world_id} ({w.name})")
|
|
732
|
+
typer.echo("branches:")
|
|
733
|
+
for b in branches:
|
|
734
|
+
forked = f" forked@{b.forked_from[:8]}" if b.forked_from else ""
|
|
735
|
+
head = b.head_commit[:8] if b.head_commit else "-"
|
|
736
|
+
day = days.get(b.branch_id, 0)
|
|
737
|
+
typer.echo(f" {b.name:<16} head={head} depth={b.head_depth} day={day}{forked}")
|
|
738
|
+
if markers:
|
|
739
|
+
typer.echo("markers:")
|
|
740
|
+
for m in markers:
|
|
741
|
+
typer.echo(f" {m.name:<16} → {m.commit_id[:8]}")
|
|
742
|
+
|
|
743
|
+
_run_async(_run)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
@branch_app.command("fork")
|
|
747
|
+
def branch_fork(
|
|
748
|
+
world: str,
|
|
749
|
+
at: str = typer.Option(..., "--at", help="marker name or commit id to fork from"),
|
|
750
|
+
name: str = typer.Option(..., "--name", help="name for the new branch"),
|
|
751
|
+
time_skip_days: int = typer.Option(
|
|
752
|
+
0, "--time-skip-days", help="advance in-fiction time on the fork (e.g. 365 = a year later)"
|
|
753
|
+
),
|
|
754
|
+
) -> None:
|
|
755
|
+
"""Fork a new branch from any commit or marker (docs/03: branch anywhere)."""
|
|
756
|
+
|
|
757
|
+
async def _run() -> None:
|
|
758
|
+
store = build_store()
|
|
759
|
+
await connect_store(store)
|
|
760
|
+
try:
|
|
761
|
+
w = await _world_or_exit(store, world)
|
|
762
|
+
branch = await store.fork_branch(w.world_id, at, name)
|
|
763
|
+
if time_skip_days > 0:
|
|
764
|
+
# agenda_tick time-skips AND fires the world's downtime agenda rules (docs/17); it
|
|
765
|
+
# needs no LLM, so a stub router suffices. A rule-less world → a plain time-skip.
|
|
766
|
+
engine = Engine(store, build_router("stub", None))
|
|
767
|
+
await engine.agenda_tick(branch.branch_id, time_skip_days)
|
|
768
|
+
finally:
|
|
769
|
+
await store.close()
|
|
770
|
+
forked = branch.forked_from[:8] if branch.forked_from else "-"
|
|
771
|
+
typer.echo(f"forked branch {name!r}: {branch.branch_id}")
|
|
772
|
+
typer.echo(f" from {forked} (head = {forked})")
|
|
773
|
+
if time_skip_days > 0:
|
|
774
|
+
typer.echo(f" time-skipped {time_skip_days} day(s) on the fork")
|
|
775
|
+
|
|
776
|
+
_run_async(_run)
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
@branch_app.command("mark")
|
|
780
|
+
def branch_mark(
|
|
781
|
+
world: str,
|
|
782
|
+
name: str,
|
|
783
|
+
branch: str = typer.Option("main", "--branch", help="branch whose head to mark"),
|
|
784
|
+
) -> None:
|
|
785
|
+
"""Mark a branch's current head with a name (a fork root, docs/03)."""
|
|
786
|
+
|
|
787
|
+
async def _run() -> None:
|
|
788
|
+
store = build_store()
|
|
789
|
+
await connect_store(store)
|
|
790
|
+
try:
|
|
791
|
+
w = await _world_or_exit(store, world)
|
|
792
|
+
b = await store.get_branch_by_name(w.world_id, branch)
|
|
793
|
+
if b is None:
|
|
794
|
+
typer.echo(f"no such branch: {branch}", err=True)
|
|
795
|
+
raise typer.Exit(1)
|
|
796
|
+
marker = await store.create_marker(w.world_id, name, b.branch_id)
|
|
797
|
+
finally:
|
|
798
|
+
await store.close()
|
|
799
|
+
typer.echo(f"marker {marker.name!r} → commit {marker.commit_id[:8]} on branch {branch!r}")
|
|
800
|
+
|
|
801
|
+
_run_async(_run)
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
@app.command()
|
|
805
|
+
def log(
|
|
806
|
+
world: str,
|
|
807
|
+
branch: str = typer.Option("main", "--branch", help="branch to view (default main)"),
|
|
808
|
+
limit: int = typer.Option(20, "--limit", help="max commits to show"),
|
|
809
|
+
) -> None:
|
|
810
|
+
"""Commit lineage for a branch, git-log style (docs/08). Per-branch — never a merge."""
|
|
811
|
+
|
|
812
|
+
async def _run() -> None:
|
|
813
|
+
store = build_store()
|
|
814
|
+
await connect_store(store)
|
|
815
|
+
try:
|
|
816
|
+
w = await _world_or_exit(store, world)
|
|
817
|
+
b = await store.get_branch_by_name(w.world_id, branch)
|
|
818
|
+
if b is None:
|
|
819
|
+
typer.echo(f"no such branch: {branch}", err=True)
|
|
820
|
+
raise typer.Exit(1)
|
|
821
|
+
entries = await store.lineage(b.branch_id, limit)
|
|
822
|
+
finally:
|
|
823
|
+
await store.close()
|
|
824
|
+
typer.echo(f"world {w.name} branch {branch!r} (head depth {b.head_depth})")
|
|
825
|
+
for e in entries:
|
|
826
|
+
marks = f" [{', '.join(e.markers)}]" if e.markers else ""
|
|
827
|
+
typer.echo(f" {e.depth:>4} {e.commit_id[:8]} {e.summary}{marks}")
|
|
828
|
+
|
|
829
|
+
_run_async(_run)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
@campaign_app.command("new")
|
|
833
|
+
def campaign_new(
|
|
834
|
+
world: str,
|
|
835
|
+
branch: str = typer.Option("main", "--branch", help="branch to play on (default main)"),
|
|
836
|
+
adopt: str = typer.Option(None, "--adopt", help="adopt an existing actor id as the PC"),
|
|
837
|
+
pc: str = typer.Option(None, "--pc", help="create a fresh PC with this name"),
|
|
838
|
+
participant: str = typer.Option(PARTICIPANT, "--participant", help="participant id"),
|
|
839
|
+
seed: int = typer.Option(
|
|
840
|
+
0, "--seed", help="mechanics RNG seed (docs/18 G-3) — pin it for reproducible combat"
|
|
841
|
+
),
|
|
842
|
+
) -> None:
|
|
843
|
+
"""Start a campaign on a branch, binding a PC (adopt an existing actor, or create one)."""
|
|
844
|
+
|
|
845
|
+
async def _run() -> None:
|
|
846
|
+
if (adopt is None) == (pc is None):
|
|
847
|
+
typer.echo("provide exactly one of --adopt <actor_id> or --pc <name>", err=True)
|
|
848
|
+
raise typer.Exit(1)
|
|
849
|
+
store = build_store()
|
|
850
|
+
await connect_store(store)
|
|
851
|
+
try:
|
|
852
|
+
w = await _world_or_exit(store, world)
|
|
853
|
+
b = await store.get_branch_by_name(w.world_id, branch)
|
|
854
|
+
if b is None:
|
|
855
|
+
typer.echo(f"no such branch: {branch}", err=True)
|
|
856
|
+
raise typer.Exit(1)
|
|
857
|
+
# Bind the WORLD's declared ruleset (docs/06, D-30): a PbtA world's campaign is
|
|
858
|
+
# played under uro_pbta, not the hard-coded default. ('' → registry default.)
|
|
859
|
+
world_rid, world_rver = await store.world_ruleset(b.branch_id)
|
|
860
|
+
# Every PC needs a sheet so the mechanics gate can check it. A fresh PC gets one; an
|
|
861
|
+
# adopted actor is sheeted only if it lacks one (a re-adopted PC keeps its sheet).
|
|
862
|
+
pc_sheet = None
|
|
863
|
+
ruleset_id = ""
|
|
864
|
+
if pc is not None or (
|
|
865
|
+
adopt is not None and await store.get_sheet(b.branch_id, adopt) is None
|
|
866
|
+
):
|
|
867
|
+
pc_sheet, ruleset_id = _build_pc_sheet(world_rid, world_rver)
|
|
868
|
+
else:
|
|
869
|
+
ruleset_id = build_ruleset(world_rid, world_rver).id
|
|
870
|
+
campaign = await store.start_campaign(
|
|
871
|
+
w.world_id,
|
|
872
|
+
b.branch_id,
|
|
873
|
+
participant_id=participant,
|
|
874
|
+
adopt_actor_id=adopt,
|
|
875
|
+
new_pc_name=pc,
|
|
876
|
+
pc_sheet=pc_sheet,
|
|
877
|
+
starting_items=["a traveler's knife"] if pc is not None else None,
|
|
878
|
+
ruleset_id=ruleset_id,
|
|
879
|
+
ruleset_version=world_rver,
|
|
880
|
+
seed=seed,
|
|
881
|
+
)
|
|
882
|
+
finally:
|
|
883
|
+
await store.close()
|
|
884
|
+
who = f"adopted {adopt}" if adopt else f"new PC {pc!r}"
|
|
885
|
+
typer.echo(f"campaign: {campaign.campaign_id} (branch {branch!r}, {who})")
|
|
886
|
+
typer.echo(f"play it: uro play {campaign.campaign_id}")
|
|
887
|
+
|
|
888
|
+
_run_async(_run)
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
@campaign_app.command("end")
|
|
892
|
+
def campaign_end(
|
|
893
|
+
campaign_id: str,
|
|
894
|
+
marker: str = typer.Option(..., "--marker", help="name the closing commit (a fork root)"),
|
|
895
|
+
outcome: str = typer.Option("", "--outcome", help="short outcome note"),
|
|
896
|
+
) -> None:
|
|
897
|
+
"""End a campaign: release its PCs to world NPCs and mark the closing commit."""
|
|
898
|
+
|
|
899
|
+
async def _run() -> None:
|
|
900
|
+
store = build_store()
|
|
901
|
+
await connect_store(store)
|
|
902
|
+
try:
|
|
903
|
+
m = await store.end_campaign(campaign_id, marker, outcome=outcome)
|
|
904
|
+
finally:
|
|
905
|
+
await store.close()
|
|
906
|
+
typer.echo(f"campaign ended; marker {m.name!r} → commit {m.commit_id[:8]}")
|
|
907
|
+
|
|
908
|
+
_run_async(_run)
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
@campaign_app.command("join")
|
|
912
|
+
def campaign_join(
|
|
913
|
+
campaign_id: str,
|
|
914
|
+
participant: str = typer.Option(..., "--participant", help="joining participant id"),
|
|
915
|
+
adopt: str = typer.Option(None, "--adopt", help="adopt an existing actor as this PC"),
|
|
916
|
+
pc: str = typer.Option(None, "--pc", help="create a fresh PC with this name"),
|
|
917
|
+
) -> None:
|
|
918
|
+
"""Seat an ADDITIONAL participant on a live campaign, binding their OWN PC (docs/08, OQ-7) —
|
|
919
|
+
the party-join path. Round-robin free-roam then rotates turns across the party (`uro serve`
|
|
920
|
+
with multiple --token spins up a PartyArbiter)."""
|
|
921
|
+
|
|
922
|
+
async def _run() -> None:
|
|
923
|
+
if (adopt is None) == (pc is None):
|
|
924
|
+
typer.echo("provide exactly one of --adopt <actor_id> or --pc <name>", err=True)
|
|
925
|
+
raise typer.Exit(1)
|
|
926
|
+
store = build_store()
|
|
927
|
+
await connect_store(store)
|
|
928
|
+
try:
|
|
929
|
+
campaign = await store.get_campaign(campaign_id)
|
|
930
|
+
if campaign is None:
|
|
931
|
+
typer.echo(f"no such campaign: {campaign_id}", err=True)
|
|
932
|
+
raise typer.Exit(1)
|
|
933
|
+
# Sheet a fresh PC (or an adopted actor lacking one) from the campaign's bound ruleset.
|
|
934
|
+
pc_sheet = None
|
|
935
|
+
if pc is not None or (
|
|
936
|
+
adopt is not None and await store.get_sheet(campaign.branch_id, adopt) is None
|
|
937
|
+
):
|
|
938
|
+
pc_sheet, _ = _build_pc_sheet(campaign.ruleset_id, campaign.ruleset_version)
|
|
939
|
+
actor = await store.bind_pc(
|
|
940
|
+
campaign_id,
|
|
941
|
+
participant,
|
|
942
|
+
adopt_actor_id=adopt,
|
|
943
|
+
new_pc_name=pc,
|
|
944
|
+
pc_sheet=pc_sheet,
|
|
945
|
+
starting_items=["a traveler's knife"] if pc is not None else None,
|
|
946
|
+
ruleset_id=campaign.ruleset_id,
|
|
947
|
+
)
|
|
948
|
+
finally:
|
|
949
|
+
await store.close()
|
|
950
|
+
typer.echo(f"joined: participant {participant!r} → PC {actor}")
|
|
951
|
+
|
|
952
|
+
_run_async(_run)
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
@app.command("dry-run")
|
|
956
|
+
def dry_run(
|
|
957
|
+
campaign_id: str,
|
|
958
|
+
intent: str,
|
|
959
|
+
provider: str = typer.Option("stub", help="stub | local | openai | anthropic"),
|
|
960
|
+
model: str = typer.Option(None, help="model id for local/openai/anthropic providers"),
|
|
961
|
+
role_model: list[str] = typer.Option( # noqa: B008 (typer DI-style default, like every option here)
|
|
962
|
+
None, "--role-model", help="per-role model override, repeatable: role=spec (docs/04)"
|
|
963
|
+
),
|
|
964
|
+
) -> None:
|
|
965
|
+
"""Dry-run a beat (docs/09 creator loop): show the events it WOULD commit, without writing.
|
|
966
|
+
Nothing enters the log — the campaign is untouched."""
|
|
967
|
+
role_models = parse_role_models(role_model)
|
|
968
|
+
|
|
969
|
+
async def _run() -> None:
|
|
970
|
+
store = build_store()
|
|
971
|
+
await connect_store(store)
|
|
972
|
+
try:
|
|
973
|
+
campaign = await store.get_campaign(campaign_id)
|
|
974
|
+
if campaign is None:
|
|
975
|
+
typer.echo(f"no such campaign: {campaign_id}", err=True)
|
|
976
|
+
raise typer.Exit(1)
|
|
977
|
+
engine = Engine(
|
|
978
|
+
store,
|
|
979
|
+
build_router(provider, model, role_models),
|
|
980
|
+
ruleset=build_ruleset(campaign.ruleset_id, campaign.ruleset_version),
|
|
981
|
+
)
|
|
982
|
+
events = await engine.preview_beat(campaign, PARTICIPANT, intent)
|
|
983
|
+
finally:
|
|
984
|
+
await store.close()
|
|
985
|
+
typer.echo(f"dry-run {intent!r}: {len(events)} event(s) would commit (nothing written):")
|
|
986
|
+
for e in events:
|
|
987
|
+
refs = f" → {e.entity_refs}" if e.entity_refs else ""
|
|
988
|
+
typer.echo(f" {e.event_type}{refs}")
|
|
989
|
+
|
|
990
|
+
_run_async(_run)
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
@app.command()
|
|
994
|
+
def consistency(campaign_id: str) -> None:
|
|
995
|
+
"""Report the narrator contradiction-survival rate (thesis proxy metric T2).
|
|
996
|
+
|
|
997
|
+
Counts narrator-asserted claims that survived the extractor's contradiction gauntlet
|
|
998
|
+
(i.e. were not flagged as contradicting recalled state). This is a PROXY — it only
|
|
999
|
+
catches contradictions the extractor self-flagged, not all narration-vs-truth
|
|
1000
|
+
disagreement — best read as a regression trend, not ground-truth verification.
|
|
1001
|
+
"""
|
|
1002
|
+
|
|
1003
|
+
async def _run() -> None:
|
|
1004
|
+
store = build_store()
|
|
1005
|
+
await connect_store(store)
|
|
1006
|
+
try:
|
|
1007
|
+
campaign = await store.get_campaign(campaign_id)
|
|
1008
|
+
if campaign is None:
|
|
1009
|
+
typer.echo(f"no such campaign: {campaign_id}", err=True)
|
|
1010
|
+
raise typer.Exit(1)
|
|
1011
|
+
consistent, total = await store.fact_consistency(campaign.branch_id)
|
|
1012
|
+
finally:
|
|
1013
|
+
await store.close()
|
|
1014
|
+
ratio = consistent / total if total else 1.0
|
|
1015
|
+
typer.echo(
|
|
1016
|
+
f"T2 (proxy): {consistent}/{total} narrator claims survived the contradiction "
|
|
1017
|
+
f"gauntlet ({ratio:.0%}) — regression trend, not ground-truth verification"
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
_run_async(_run)
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
@codex_app.command("add")
|
|
1024
|
+
def codex_add(
|
|
1025
|
+
campaign_id: str,
|
|
1026
|
+
text: str,
|
|
1027
|
+
participant: str = typer.Option(PARTICIPANT, "--participant", help="whose codex"),
|
|
1028
|
+
key: str = typer.Option(
|
|
1029
|
+
None, "--key", help="dedup key (re-adding overwrites); default: content hash"
|
|
1030
|
+
),
|
|
1031
|
+
pinned: bool = typer.Option(False, "--pinned", help="always surface (vs only when mentioned)"),
|
|
1032
|
+
ref: list[str] = typer.Option( # noqa: B008 (typer DI-style default)
|
|
1033
|
+
None,
|
|
1034
|
+
"--ref",
|
|
1035
|
+
help="entity trigger, repeatable: a term/name that surfaces this note (e.g. vault)",
|
|
1036
|
+
),
|
|
1037
|
+
) -> None:
|
|
1038
|
+
"""Record an out-of-world player note (docs/18 B8) that survives a fork — the player's private
|
|
1039
|
+
edge across loops/lives. It never becomes canon and no NPC ever knows it."""
|
|
1040
|
+
|
|
1041
|
+
async def _run() -> None:
|
|
1042
|
+
store = build_store()
|
|
1043
|
+
await connect_store(store)
|
|
1044
|
+
try:
|
|
1045
|
+
campaign = await store.get_campaign(campaign_id)
|
|
1046
|
+
if campaign is None:
|
|
1047
|
+
typer.echo(f"no such campaign: {campaign_id}", err=True)
|
|
1048
|
+
raise typer.Exit(1)
|
|
1049
|
+
used = await store.participant_remember(
|
|
1050
|
+
participant, campaign.world_id, text, key=key, pinned=pinned, entity_refs=ref or []
|
|
1051
|
+
)
|
|
1052
|
+
finally:
|
|
1053
|
+
await store.close()
|
|
1054
|
+
typer.echo(
|
|
1055
|
+
f"codex: noted for {participant!r} (key {used!r}){' [pinned]' if pinned else ''}"
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1058
|
+
_run_async(_run)
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
@codex_app.command("list")
|
|
1062
|
+
def codex_list(
|
|
1063
|
+
campaign_id: str,
|
|
1064
|
+
participant: str = typer.Option(PARTICIPANT, "--participant", help="whose codex"),
|
|
1065
|
+
) -> None:
|
|
1066
|
+
"""List a participant's out-of-world notes for this campaign's world."""
|
|
1067
|
+
|
|
1068
|
+
async def _run() -> None:
|
|
1069
|
+
store = build_store()
|
|
1070
|
+
await connect_store(store)
|
|
1071
|
+
try:
|
|
1072
|
+
campaign = await store.get_campaign(campaign_id)
|
|
1073
|
+
if campaign is None:
|
|
1074
|
+
typer.echo(f"no such campaign: {campaign_id}", err=True)
|
|
1075
|
+
raise typer.Exit(1)
|
|
1076
|
+
notes = await store.participant_notes(participant, campaign.world_id)
|
|
1077
|
+
finally:
|
|
1078
|
+
await store.close()
|
|
1079
|
+
if not notes:
|
|
1080
|
+
typer.echo(f"codex empty for {participant!r}")
|
|
1081
|
+
return
|
|
1082
|
+
for n in notes:
|
|
1083
|
+
tag = (
|
|
1084
|
+
" [pinned]"
|
|
1085
|
+
if n.pinned
|
|
1086
|
+
else (f" (on: {', '.join(n.entity_refs)})" if n.entity_refs else "")
|
|
1087
|
+
)
|
|
1088
|
+
typer.echo(f"- {n.text}{tag}")
|
|
1089
|
+
|
|
1090
|
+
_run_async(_run)
|