pyyol 1.2.1 → 1.4.0

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.
package/README.md CHANGED
@@ -76,6 +76,25 @@ The one rule that matters: **you can never lose money by accident.**
76
76
  flag, a certified agent, and a one-time confirmation. Precedence: `--ranked` >
77
77
  `PYYOL_MODE` > `pyyol.toml` > sandbox.
78
78
 
79
+ ## Verified LLM agents (model, tokens & cost)
80
+
81
+ Drive your moves with an LLM and Pyyol captures the exact **model, tokens, and cost**
82
+ for every turn — automatically. Two lines:
83
+
84
+ ```ts
85
+ import pyyol from "pyyol";
86
+ import OpenAI from "openai";
87
+
88
+ await pyyol.instrument(); // capture usage on every LLM call
89
+ const client = pyyol.route(new OpenAI()); // in ranked, route through the gateway (verified)
90
+ // ...call client inside step(); usage is attached to your move for you.
91
+ ```
92
+
93
+ In sandbox this records estimated cost; in ranked it routes through the Pyyol Gateway
94
+ so the numbers are server-observed (unfakeable) and you earn the blue **Verified**
95
+ badge. `route()` is a safe no-op in sandbox. Runnable example:
96
+ [`examples/llm-agent.ts`](examples/llm-agent.ts).
97
+
79
98
  ## Games
80
99
 
81
100
  Three games are available; each is documented in
@@ -222,10 +241,18 @@ the protocol into an existing framework (Express, Fastify, a serverless handler)
222
241
 
223
242
  ## Commands
224
243
 
225
- `login` · `logout` · `whoami` · `init` · `dev` · `play` · `publish` · `replay` ·
226
- `profile` · `leaderboard` · `arenas` · `doctor` · `update`. Run `pyyol --help` for
227
- details, or `pyyol doctor` to diagnose your setup. Config lives in a tiny
228
- **`pyyol.toml`** (convention over configuration — no manifest files).
244
+ Auth & scaffold: `login` · `logout` · `whoami` · `init` · `doctor`.
245
+ Play: `dev` · `play` · `watch` · `replay`.
246
+ Deploy-once: `serve` · `autoplay` · `run`.
247
+ Ranked: `publish --manifest <file>`.
248
+ Discovery & offline: `arenas` · `profile` · `leaderboard` · `simulate` · `validate` ·
249
+ `status` · `logs` · `update`.
250
+
251
+ Run `pyyol --help` for details, or `pyyol doctor` to diagnose your setup. Config lives
252
+ in a tiny **`pyyol.toml`** (convention over configuration — no manifest files).
253
+
254
+ > `queue` and `wallet` are Python-only today; in JS, enter ranked inline with
255
+ > `pyyol play <game> --ranked`. Full reference: the docs "CLI reference" page.
229
256
 
230
257
  ## Security
231
258
 
package/dist/adapter.d.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import { Agent } from "./server.js";
2
- export declare abstract class Adapter {
2
+ export declare abstract class Adapter<View = unknown, Move = unknown> {
3
3
  name: string;
4
4
  supportedGames: string[];
5
5
  secret: string;
6
6
  /** Called once at match start (optional). Return an ack object or nothing. */
7
7
  initialize(_ctx: unknown): unknown;
8
- /** Decide one move for `view` and return it. REQUIRED. */
9
- abstract step(view: unknown): unknown;
8
+ /** Decide one move for `view` and return it. REQUIRED. Async is supported. */
9
+ abstract step(view: View): Move | Promise<Move>;
10
10
  /** Called once when the match ends (optional). */
11
11
  shutdown(_result: unknown): void;
12
12
  /** Build the underlying Agent that drives the real transport. */
package/dist/adapter.js CHANGED
@@ -6,13 +6,20 @@
6
6
  *
7
7
  * import { Adapter } from "pyyol";
8
8
  *
9
- * class Atlas extends Adapter {
9
+ * import { Adapter } from "pyyol";
10
+ * import type { GoofspielView, GoofspielMove } from "pyyol";
11
+ *
12
+ * // Typed: `view` is a GoofspielView and the return is checked.
13
+ * class Atlas extends Adapter<GoofspielView, GoofspielMove> {
10
14
  * name = "atlas";
11
15
  * supportedGames = ["goofspiel"];
12
16
  * step(view) { return { round: view.round, card: Math.min(...view.legal_actions) }; }
13
17
  * }
14
18
  *
15
19
  * export default new Atlas(); // pyyol dev / play discover this via pyyol.toml
20
+ *
21
+ * The type params are optional and default to `unknown` (so `extends Adapter` keeps
22
+ * working); supply `Adapter<View, Move>` to get a typed `view` and a checked return.
16
23
  */
17
24
  import { SUPPORTED_GAMES } from "./models.js";
18
25
  import { Agent } from "./server.js";
package/dist/cli.js CHANGED
@@ -11,6 +11,8 @@ import { fileURLToPath, pathToFileURL } from "node:url";
11
11
  import { asAgent } from "./adapter.js";
12
12
  import * as config from "./config.js";
13
13
  import * as creds from "./credentials.js";
14
+ import { enableGateway } from "./instrument.js";
15
+ import { maybeInstallPing } from "./install-ping.js";
14
16
  import { deriveConnectUrl, runLoginFlow } from "./login.js";
15
17
  import * as mode from "./mode.js";
16
18
  import { RuntimeConnector } from "./runtime.js";
@@ -18,6 +20,11 @@ import { REQUEST_ID_HEADER, SIGNATURE_HEADER, SIGNATURE_VERSION, TIMESTAMP_HEADE
18
20
  import { SDK_VERSION } from "./version.js";
19
21
  const OK = "✓";
20
22
  const BAD = "✗";
23
+ const WARN = "•";
24
+ // N-player games use the group matchmaking queue; Goofspiel (1v1) uses the 2-player
25
+ // queue. Same enqueue request shape, different endpoint.
26
+ const GROUP_GAMES = new Set(["mafia", "monopoly"]);
27
+ const queuePathFor = (game) => (GROUP_GAMES.has(game) ? "/v1/group-queue" : "/v1/queue");
21
28
  // Public platform defaults. `pyyol login` with no flags hits the live platform;
22
29
  // self-hosted/local users override via PYYOL_API / PYYOL_DASHBOARD (or --api /
23
30
  // --dashboard). The API host serves /v1/*; the dashboard host serves /cli-login —
@@ -25,6 +32,10 @@ const BAD = "✗";
25
32
  // to the API host.
26
33
  const DEFAULT_API_BASE = (process.env.PYYOL_API || "").replace(/\/$/, "") || "https://api.pyyol.com";
27
34
  const DEFAULT_DASHBOARD = (process.env.PYYOL_DASHBOARD || "").replace(/\/$/, "") || "https://pyyol.com";
35
+ // Verified-tier LLM gateway base (Phase 4). Ranked mode enables gateway routing so
36
+ // pyyol.route(client) sends the agent's LLM calls through it for server-observed
37
+ // (unfakeable) model/token/cost. Override with $PYYOL_GATEWAY.
38
+ const DEFAULT_GATEWAY = (process.env.PYYOL_GATEWAY || "").replace(/\/$/, "") || "https://gateway.pyyol.com";
28
39
  // Agent API keys look like "sk_arena_<lookup>_<secret>" — the long-lived, revocable
29
40
  // connection credential (mirrors backend platform.PrefixKey).
30
41
  const AGENT_KEY_PREFIX = "sk_arena_";
@@ -222,6 +233,42 @@ async function loadAgentFromConfig(cfg) {
222
233
  return asAgent(obj);
223
234
  }
224
235
  // ── commands ───────────────────────────────────────────────────────────────────
236
+ async function loginAndSave(api, dashboard, connect, provider) {
237
+ const c = await runLoginFlow({ dashboardUrl: dashboard, apiUrl: api, provider });
238
+ if (connect)
239
+ c.connectUrl = connect;
240
+ if (!c.apiKey && c.agentId && c.accessToken) {
241
+ const [st, resp] = await apiPost(`${api}/v1/agent/keys`, c.accessToken, { agent_id: c.agentId });
242
+ if (st === 201 && resp.api_key)
243
+ c.apiKey = resp.api_key;
244
+ }
245
+ creds.save(c);
246
+ return c;
247
+ }
248
+ // Return valid creds for a game/sandbox command, launching the browser login when this
249
+ // DEVICE isn't logged in — so `pyyol dev`/`play`/`queue` just work after install.
250
+ // Non-interactive (CI) → null with guidance so the caller errors cleanly.
251
+ async function ensureLogin(a) {
252
+ const c = creds.load();
253
+ if (c && (c.accessToken || c.apiKey))
254
+ return c;
255
+ const api = (str(a, "api") || DEFAULT_API_BASE).replace(/\/$/, "");
256
+ const dashboard = (str(a, "dashboard") || DEFAULT_DASHBOARD).replace(/\/$/, "");
257
+ if (!(process.stdin.isTTY && process.stdout.isTTY)) {
258
+ console.error(`${BAD} not logged in on this device. Run \`pyyol login\` (opens the browser) or set PYYOL_TOKEN, then retry.`);
259
+ return null;
260
+ }
261
+ console.log("you're not logged in on this device — opening the browser to sign in…");
262
+ try {
263
+ const got = await loginAndSave(api, dashboard, str(a, "connect") || "", str(a, "with") || "");
264
+ console.log(`${OK} logged in as ${got.agentId || "(no agent yet)"}. continuing…`);
265
+ return got;
266
+ }
267
+ catch (e) {
268
+ console.error(`${BAD} login failed: ${e} — run \`pyyol login\` and retry.`);
269
+ return null;
270
+ }
271
+ }
225
272
  async function cmdLogin(a) {
226
273
  const token = str(a, "token");
227
274
  // API host serves /v1/*; dashboard host serves /cli-login — different in prod,
@@ -322,7 +369,11 @@ class ${cls} extends Adapter {
322
369
  supportedGames = ["${arena}"];
323
370
 
324
371
  step(view) {
325
- // Your strategy goes here (call any framework or LLM). Baseline below:
372
+ // Your strategy goes here (call any framework or LLM). Baseline below.
373
+ // Driving moves with an LLM? Capture the real model/tokens/cost for free:
374
+ // import pyyol from "pyyol"; await pyyol.instrument(); // once, at the top
375
+ // const client = pyyol.route(new OpenAI()); // in ranked, routes via the gateway
376
+ // then call \`client\` here. See docs -> "Verified LLM agents".
326
377
  const legal = view.legal_actions ?? [];
327
378
  ${arena === "goofspiel" ? "return { round: view.round, card: Math.min(...legal) };" : "return legal.length ? { action: legal[0] } : {};"}
328
379
  }
@@ -372,13 +423,11 @@ async function orchestrate(a, devLocked) {
372
423
  console.error(`${BAD} no pyyol.toml here — run \`pyyol init <dir>\` first.`);
373
424
  return 2;
374
425
  }
375
- const c = creds.load();
376
- // The connection runs on the agent key OR the dashboard JWT either proves a
377
- // session. (The agent key is the persistent, no-expiry one.)
378
- if (!c || !(c.accessToken || c.apiKey)) {
379
- console.error(`${BAD} not logged in — run \`pyyol login\` first.`);
426
+ // Auto-login on this device if needed: a first-time user who installed the SDK and
427
+ // ran `pyyol dev`/`play` gets the browser sign-in, then playsno separate step.
428
+ const c = await ensureLogin(a);
429
+ if (!c)
380
430
  return 2;
381
- }
382
431
  const connectUrl = str(a, "url") || process.env.PYYOL_URL || c.connectUrl;
383
432
  const base = httpBase(a, c);
384
433
  // The agent id MUST be the one the token belongs to. The token comes from creds,
@@ -398,6 +447,19 @@ async function orchestrate(a, devLocked) {
398
447
  console.log("aborted — staying safe. (Use --yes in CI to skip the prompt.)");
399
448
  return 1;
400
449
  }
450
+ // Enable verified-tier gateway routing (only with an agent key — the gateway
451
+ // authenticates X-Pyyol-Key via it; a dashboard JWT can't). Then pyyol.route(client)
452
+ // sends the agent's LLM calls through the gateway for server-observed model/cost.
453
+ if (usingAgentKey && token) {
454
+ enableGateway(token, DEFAULT_GATEWAY);
455
+ console.log(` ${OK} verified gateway routing on (${DEFAULT_GATEWAY}) — call pyyol.route(client)`);
456
+ }
457
+ else {
458
+ // Don't silently run unverified: the dev thinks they're competing verified.
459
+ console.error(` ${BAD} verified gateway routing OFF — no agent key in this session ` +
460
+ `(a dashboard-JWT login can't authenticate to the gateway). Run \`pyyol login\` ` +
461
+ `to mint an agent key; your ranked LLM cost won't be verified.`);
462
+ }
401
463
  }
402
464
  if (agentId && !cfg.agent_id)
403
465
  config.setAgentId(agentId);
@@ -428,7 +490,7 @@ async function orchestrate(a, devLocked) {
428
490
  setTimeout(async () => {
429
491
  if (m === mode.RANKED) {
430
492
  const tier = str(a, "tier") || "low";
431
- const [st, resp] = await apiPost(`${base}/v1/queue`, token, { game: arena, tier });
493
+ const [st, resp] = await apiPost(`${base}${queuePathFor(arena)}`, token, { game: arena, tier });
432
494
  if (st === 200 || st === 202)
433
495
  console.log(` ${OK} queued for RANKED ${arena} (tier ${tier})`);
434
496
  else if (String(resp.code ?? "").includes("certified"))
@@ -459,6 +521,42 @@ async function orchestrate(a, devLocked) {
459
521
  console.log("\nstopped.");
460
522
  return 0;
461
523
  }
524
+ async function cmdGames(a) {
525
+ const base = httpBase(a, creds.load());
526
+ if (!base) {
527
+ console.error(`${BAD} no API url — pass --api or run \`pyyol login\`.`);
528
+ return 2;
529
+ }
530
+ const [st, resp] = await apiGet(`${base}/v1/games`);
531
+ if (st !== 200) {
532
+ console.error(`${BAD} could not fetch games (${st}): ${JSON.stringify(resp)}`);
533
+ return 1;
534
+ }
535
+ const games = resp.games ?? [];
536
+ if (!games.length) {
537
+ console.log("no games available.");
538
+ return 0;
539
+ }
540
+ console.log(` ${"GAME".padEnd(11)}${"LIVE".padStart(6)}${"PLAYING".padStart(9)}${"WAITING".padStart(9)} STATUS`);
541
+ console.log(` ${"─".repeat(44)}`);
542
+ let totalLive = 0;
543
+ let totalWait = 0;
544
+ for (const g of games) {
545
+ const live = Number(g.live ?? 0);
546
+ const playing = Number(g.playing ?? 0);
547
+ const waiting = Number(g.waiting ?? 0);
548
+ totalLive += live;
549
+ totalWait += waiting;
550
+ const status = live > 0 ? `${OK} ${live} live` : waiting > 0 ? `${waiting} waiting — queue to start` : "quiet — be the first";
551
+ console.log(` ${String(g.game ?? "?").padEnd(11)}${String(live).padStart(6)}${String(playing).padStart(9)}${String(waiting).padStart(9)} ${status}`);
552
+ }
553
+ console.log(` ${"─".repeat(44)}`);
554
+ if (totalLive === 0 && totalWait === 0)
555
+ console.log(" nothing running right now — `pyyol queue <game>` to open a table.");
556
+ else
557
+ console.log(` ${totalLive} live match(es), ${totalWait} agent(s) waiting. \`pyyol queue <game>\` to join.`);
558
+ return 0;
559
+ }
462
560
  async function cmdArenas(a) {
463
561
  const base = httpBase(a, creds.load());
464
562
  if (!base) {
@@ -478,6 +576,110 @@ async function cmdArenas(a) {
478
576
  }
479
577
  return 0;
480
578
  }
579
+ /** `pyyol wallet` — show the owner's coin balance + per-agent wallets, so a dev can
580
+ * see why ranked was refused ("not enough coins") without leaving the CLI. Parity
581
+ * with the Python CLI. Owner-scoped, so it uses the dashboard access token. */
582
+ async function cmdWallet(a) {
583
+ const c = creds.load();
584
+ const base = httpBase(a, c);
585
+ const token = c?.accessToken || str(a, "token") || process.env.PYYOL_TOKEN || "";
586
+ if (!token) {
587
+ console.error(`${BAD} not logged in — run \`pyyol login\` first.`);
588
+ return 2;
589
+ }
590
+ const [st, w] = await apiGet(`${base}/v1/user/wallet`, token);
591
+ if (st !== 200) {
592
+ console.error(`${BAD} could not fetch wallet (${st}): ${JSON.stringify(w)}`);
593
+ return 1;
594
+ }
595
+ if (bool(a, "json")) {
596
+ console.log(JSON.stringify(w, null, 2));
597
+ return 0;
598
+ }
599
+ const cents = Number(w.coin_cents ?? 1) || 1;
600
+ const usd = (coins) => `$${((coins * cents) / 100).toFixed(2)}`;
601
+ const avail = Number(w.available_balance ?? 0);
602
+ console.log("Treasury");
603
+ console.log(` Available ${avail.toLocaleString()} coins (${usd(avail)})`);
604
+ if (w.locked_balance)
605
+ console.log(` Locked ${Number(w.locked_balance).toLocaleString()} coins (in active matches)`);
606
+ if (w.lifetime_earnings)
607
+ console.log(` Earned ${Number(w.lifetime_earnings).toLocaleString()} coins (lifetime)`);
608
+ const agents = w.agents ?? [];
609
+ if (agents.length) {
610
+ console.log("\nAgent wallets");
611
+ for (const ag of agents) {
612
+ const bal = Number(ag.balance ?? 0).toLocaleString();
613
+ const wd = Number(ag.withdrawable ?? 0).toLocaleString();
614
+ console.log(` ${String(ag.name ?? ag.agent ?? "?").padEnd(20)} ${bal.padStart(10)} coins withdrawable ${wd}`);
615
+ }
616
+ }
617
+ return 0;
618
+ }
619
+ /** `pyyol queue <game> [--tier low|mid|high | --bid N] [--list]` — enter ranked
620
+ * matchmaking at a stake tier (parity with the Python CLI). `--list` shows the
621
+ * admin-configured tiers. The game is a POSITIONAL argument. */
622
+ async function cmdQueue(a) {
623
+ const c = creds.load();
624
+ const base = httpBase(a, c);
625
+ if (!base) {
626
+ console.error(`${BAD} no API url — pass --api or run \`pyyol login\`.`);
627
+ return 2;
628
+ }
629
+ const game = a.positionals[0] ?? "";
630
+ if (!game) {
631
+ console.error(`${BAD} usage: pyyol queue <game> [--tier low|mid|high | --bid N] [--list]`);
632
+ return 2;
633
+ }
634
+ if (bool(a, "list")) {
635
+ const [st, resp] = await apiGet(`${base}/v1/games/${game}/stakes`);
636
+ if (st !== 200) {
637
+ console.error(`${BAD} could not fetch tiers (${st})`);
638
+ return 1;
639
+ }
640
+ const tiers = resp.tiers ?? [];
641
+ if (!tiers.length) {
642
+ console.log(`no stake tiers configured for ${game} — use --bid <coins>`);
643
+ return 0;
644
+ }
645
+ console.log(`${game} stake tiers:`);
646
+ for (const t of tiers)
647
+ console.log(` ${String(t.key ?? "").padEnd(8)} ${String(Number(t.coins ?? 0)).padStart(8)} coins ${t.label ?? ""}`);
648
+ return 0;
649
+ }
650
+ // Queuing needs a session — auto-launch login on this device if absent.
651
+ let token = c?.accessToken || str(a, "token") || process.env.PYYOL_TOKEN || "";
652
+ if (!token) {
653
+ const got = await ensureLogin(a);
654
+ if (!got)
655
+ return 2;
656
+ token = got.accessToken || got.apiKey || "";
657
+ }
658
+ const body = { game };
659
+ if (str(a, "tier"))
660
+ body.tier = str(a, "tier");
661
+ else if (num(a, "bid", 0) > 0)
662
+ body.bid = num(a, "bid", 0);
663
+ else {
664
+ console.error(`${BAD} choose a stake: --tier <low|mid|high> (see \`pyyol queue ${game} --list\`) or --bid <coins>`);
665
+ return 2;
666
+ }
667
+ const [st, resp] = await apiPost(`${base}${queuePathFor(game)}`, token, body);
668
+ if (st !== 200 && st !== 202) {
669
+ const code = String(resp.code ?? resp.error ?? "");
670
+ if (code.includes("certified"))
671
+ console.error(`${BAD} agent not certified — run \`pyyol publish --manifest <file>\` first.`);
672
+ else if (code.includes("balance") || code.includes("insufficient"))
673
+ console.error(`${BAD} not enough coins — fund your wallet (see \`pyyol wallet\`).`);
674
+ else
675
+ console.error(`${BAD} could not queue ranked (${st}): ${JSON.stringify(resp)}`);
676
+ return 1;
677
+ }
678
+ console.log(` ${OK} queued for ${game}${body.tier ? ` (tier ${body.tier})` : ""} — keep your agent connected; it plays when matched.`);
679
+ if (resp.match_id)
680
+ console.log(` ${OK} matched → ${resp.match_id}\n watch it: pyyol watch ${resp.match_id}`);
681
+ return 0;
682
+ }
481
683
  async function cmdLeaderboard(a) {
482
684
  const base = httpBase(a, creds.load());
483
685
  if (!base) {
@@ -789,10 +991,42 @@ function autoplayOpts(a, cfg) {
789
991
  async function autoplaySet(api, token, enabled, m, bid, games) {
790
992
  return apiRequest("PUT", `${api.replace(/\/$/, "")}/v1/agent/autoplay`, token, { enabled, mode: m, bid, games });
791
993
  }
994
+ /** GET the agent's auto-play setting + last observed status (mirrors Python
995
+ * `_autoplay_get`). */
996
+ async function autoplayGet(api, token) {
997
+ return apiGet(`${api.replace(/\/$/, "")}/v1/agent/autoplay`, token);
998
+ }
999
+ const AUTOPLAY_STATUS_LABEL = {
1000
+ playing: [OK, "playing"],
1001
+ searching: [OK, "searching for an opponent"],
1002
+ paused: [WARN, "paused"],
1003
+ blocked: [BAD, "not playing"],
1004
+ };
1005
+ /** Render `pyyol autoplay status` — is it on, and WHY it is or isn't playing, so a
1006
+ * quiet auto-play agent is never a mystery. */
1007
+ function printAutoplayStatus(body) {
1008
+ if (!body?.enabled) {
1009
+ console.log(`${WARN} auto-play is OFF (turn it on with \`pyyol autoplay on\`)`);
1010
+ return;
1011
+ }
1012
+ console.log(`${OK} auto-play is ON — mode=${body.mode || "sandbox"}`);
1013
+ const status = body.last_status || "";
1014
+ const reason = body.last_status_reason || "";
1015
+ if (!status) {
1016
+ console.log(" status: starting up — no activity recorded yet (check back in a moment)");
1017
+ return;
1018
+ }
1019
+ const [marker, label] = AUTOPLAY_STATUS_LABEL[status] ?? [WARN, status];
1020
+ console.log(` ${marker} ${label}${reason ? ` — ${reason}` : ""}`);
1021
+ if (body.last_status_at)
1022
+ console.log(` as of ${body.last_status_at}`);
1023
+ if (status === "blocked")
1024
+ console.log(" fix the reason above (e.g. connect your agent with `pyyol run`), and it resumes automatically.");
1025
+ }
792
1026
  async function cmdAutoplay(a) {
793
1027
  const state = a.positionals[0];
794
- if (state !== "on" && state !== "off") {
795
- console.error(`${BAD} usage: pyyol autoplay on|off`);
1028
+ if (state !== "on" && state !== "off" && state !== "status") {
1029
+ console.error(`${BAD} usage: pyyol autoplay on|off|status`);
796
1030
  return 2;
797
1031
  }
798
1032
  const c = creds.load();
@@ -805,6 +1039,16 @@ async function cmdAutoplay(a) {
805
1039
  }
806
1040
  if (str(a, "token"))
807
1041
  warnArgvSecret();
1042
+ // `pyyol autoplay status` READS the current state + why it is/isn't playing.
1043
+ if (state === "status") {
1044
+ const [st, resp] = await autoplayGet(api, token);
1045
+ if (st >= 200 && st < 300) {
1046
+ printAutoplayStatus(resp);
1047
+ return 0;
1048
+ }
1049
+ console.error(`${BAD} failed (status ${st}): ${JSON.stringify(resp)}`);
1050
+ return 1;
1051
+ }
808
1052
  const on = state === "on";
809
1053
  const [m, games] = autoplayOpts(a, config.load());
810
1054
  const [st, resp] = await autoplaySet(api, token, on, m, num(a, "bid", 0), games);
@@ -837,7 +1081,8 @@ async function cmdLogs(a) {
837
1081
  async function cmdSimulate(a) {
838
1082
  const game = str(a, "game") || "goofspiel";
839
1083
  if (game !== "goofspiel") {
840
- console.error(`simulate currently supports goofspiel (got '${game}'); use \`validate\` for a single-turn check of any game.`);
1084
+ console.error(`simulate runs a full in-process match for goofspiel only (got '${game}'). ` +
1085
+ `For ${game}, iterate with \`pyyol dev\` — sandbox practice vs house agents, no stakes.`);
841
1086
  return 2;
842
1087
  }
843
1088
  const opponent = str(a, "opponent") || "baseline";
@@ -1230,11 +1475,14 @@ Commands:
1230
1475
  init <dir> [--arena goofspiel|mafia|monopoly] [--framework F] [--name N]
1231
1476
  dev [--matches N] local dev loop — SANDBOX, no stakes
1232
1477
  play <arena> [--ranked] [--tier] compete; --ranked = real stakes
1233
- publish (advanced) certify for ranked
1478
+ publish --manifest <file> certify your agent for ranked
1479
+ queue <game> [--tier low|mid|high | --bid N] [--list] enter ranked matchmaking
1480
+ wallet [--json] your coin balance + per-agent wallets
1234
1481
  replay <match_id> [--game] [--json]
1235
1482
  profile [handle]
1236
1483
  leaderboard [--game G] [--developers] [--season N]
1237
1484
  arenas
1485
+ games live + waiting agents per game
1238
1486
  status [--agent A] (advanced) is your agent connected?
1239
1487
  autoplay on|off [--ranked|--mode] [--bid N] [--games G,…]
1240
1488
  serve [--file F] [--var V] [--port P] [--host H] enable auto-play + run the HTTP server
@@ -1249,6 +1497,9 @@ Commands:
1249
1497
  export async function main(argv = process.argv.slice(2)) {
1250
1498
  const command = argv[0];
1251
1499
  const a = parse(argv.slice(1));
1500
+ // Anonymous, once-per-version, fire-and-forget adoption ping (opt out with
1501
+ // PYYOL_NO_TELEMETRY / DO_NOT_TRACK). Never blocks or affects the command.
1502
+ maybeInstallPing(str(a, "api") || DEFAULT_API_BASE, SDK_VERSION);
1252
1503
  switch (command) {
1253
1504
  case "login":
1254
1505
  return cmdLogin(a);
@@ -1266,10 +1517,16 @@ export async function main(argv = process.argv.slice(2)) {
1266
1517
  return cmdPublish(a);
1267
1518
  case "arenas":
1268
1519
  return cmdArenas(a);
1520
+ case "games":
1521
+ return cmdGames(a);
1269
1522
  case "leaderboard":
1270
1523
  return cmdLeaderboard(a);
1271
1524
  case "profile":
1272
1525
  return cmdProfile(a);
1526
+ case "wallet":
1527
+ return cmdWallet(a);
1528
+ case "queue":
1529
+ return cmdQueue(a);
1273
1530
  case "replay":
1274
1531
  return cmdReplay(a);
1275
1532
  case "status":
package/dist/index.d.ts CHANGED
@@ -21,5 +21,10 @@ export type { GoofspielSimOptions, GoofspielSimResult } from "./simulator.js";
21
21
  export { RuntimeConnector, ConnectorError, PROTOCOL_VERSION } from "./runtime.js";
22
22
  export type { RuntimeOptions, WebSocketLike, WebSocketCtor } from "./runtime.js";
23
23
  export { gameRules } from "./rules.js";
24
- export { Tracer, Span, currentSpan, matchTraceId } from "./telemetry.js";
25
- export type { TracerOptions, ModelCall } from "./telemetry.js";
24
+ export { Tracer, Span, currentSpan, matchTraceId, currentUsage, UsageAccumulator, runTurnUsage } from "./telemetry.js";
25
+ export type { TracerOptions, ModelCall, MoveUsage, UsageAdd } from "./telemetry.js";
26
+ export { instrument, uninstrument, recordResponse, extractUsage, patchPrototype } from "./instrument.js";
27
+ export { route, enableGateway, disableGateway, gatewayBaseUrl, gatewayHeaders } from "./instrument.js";
28
+ export type { ExtractedUsage } from "./instrument.js";
29
+ export { estimateCost, rateFor, isKnown, canonical, PRICING_VERSION } from "./pricing.js";
30
+ export type { Rate, CostArgs } from "./pricing.js";
package/dist/index.js CHANGED
@@ -17,4 +17,7 @@ export * from "./models.js";
17
17
  export { simulateGoofspiel, SimulationError } from "./simulator.js";
18
18
  export { RuntimeConnector, ConnectorError, PROTOCOL_VERSION } from "./runtime.js";
19
19
  export { gameRules } from "./rules.js";
20
- export { Tracer, Span, currentSpan, matchTraceId } from "./telemetry.js";
20
+ export { Tracer, Span, currentSpan, matchTraceId, currentUsage, UsageAccumulator, runTurnUsage } from "./telemetry.js";
21
+ export { instrument, uninstrument, recordResponse, extractUsage, patchPrototype } from "./instrument.js";
22
+ export { route, enableGateway, disableGateway, gatewayBaseUrl, gatewayHeaders } from "./instrument.js";
23
+ export { estimateCost, rateFor, isKnown, canonical, PRICING_VERSION } from "./pricing.js";
@@ -0,0 +1,2 @@
1
+ /** Fire the install ping at most once per version. Never throws. */
2
+ export declare function maybeInstallPing(apiBase: string, version: string): void;
@@ -0,0 +1,42 @@
1
+ // Anonymous, once-per-version SDK install ping (mirrors the Python SDK). The first
2
+ // time the CLI runs a given version, fire ONE best-effort ping so the platform can
3
+ // show adoption analytics. Anonymous ({sdk, version} only — the server resolves a
4
+ // COUNTRY from the request and never stores the IP), fire-and-forget (never blocks
5
+ // or errors the CLI), once per version (marker file), opt-out via PYYOL_NO_TELEMETRY
6
+ // or the standard DO_NOT_TRACK.
7
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { configDir } from "./credentials.js";
10
+ const OFF = new Set(["", "0", "false", "no", "off"]);
11
+ function optedOut() {
12
+ for (const key of ["PYYOL_NO_TELEMETRY", "DO_NOT_TRACK"]) {
13
+ const v = (process.env[key] ?? "").trim().toLowerCase();
14
+ if (v && !OFF.has(v))
15
+ return true;
16
+ }
17
+ return false;
18
+ }
19
+ /** Fire the install ping at most once per version. Never throws. */
20
+ export function maybeInstallPing(apiBase, version) {
21
+ if (!apiBase || optedOut())
22
+ return;
23
+ const marker = join(configDir(), `.install_pinged_${version}`);
24
+ try {
25
+ if (existsSync(marker))
26
+ return;
27
+ // Mark BEFORE firing: attempt at most once per version (no retry storm).
28
+ mkdirSync(configDir(), { recursive: true });
29
+ writeFileSync(marker, "");
30
+ }
31
+ catch {
32
+ return;
33
+ }
34
+ void fetch(apiBase.replace(/\/+$/, "") + "/v1/telemetry/install", {
35
+ method: "POST",
36
+ headers: { "Content-Type": "application/json" },
37
+ body: JSON.stringify({ sdk: "js", version }),
38
+ signal: AbortSignal.timeout(3000),
39
+ }).catch(() => {
40
+ /* telemetry must never surface to the CLI */
41
+ });
42
+ }
@@ -0,0 +1,41 @@
1
+ type Any = any;
2
+ /** Enable gateway routing (called by the runtime in ranked mode; safe in tests). */
3
+ export declare function enableGateway(agentKey: string, baseUrl: string): void;
4
+ export declare function disableGateway(): void;
5
+ /** The baseURL a provider client should point at, or "" if routing is off/unknown. */
6
+ export declare function gatewayBaseUrl(provider: string): string;
7
+ /** The X-Pyyol-* identity headers for the current turn (empty if routing off). */
8
+ export declare function gatewayHeaders(): Record<string, string>;
9
+ /** Point a provider client at the Pyyol Gateway (sets its baseURL). Explicit, robust
10
+ * opt-in that operates on the given instance. Returns the client. No-op when routing
11
+ * is off or the provider can't be determined. */
12
+ export declare function route<T>(client: T, provider?: string): T;
13
+ export interface ExtractedUsage {
14
+ model: string;
15
+ provider: string;
16
+ promptTokens: number;
17
+ completionTokens: number;
18
+ cachedTokens: number;
19
+ reasoningTokens: number;
20
+ }
21
+ /** Pull normalized usage from a provider response, or null if it has none.
22
+ * Handles OpenAI Chat Completions, Anthropic Messages, and the OpenAI Responses
23
+ * API; duck-typed so a plain object or an SDK object both work. */
24
+ export declare function extractUsage(resp: Any): ExtractedUsage | null;
25
+ /** Record usage from a provider response: compute cost, add to the turn
26
+ * accumulator, and emit a Lens model_call span. Returns the extracted usage (or
27
+ * null). Also the public manual hook for clients this module doesn't auto-wrap. */
28
+ export declare function recordResponse(resp: Any, o?: {
29
+ provider?: string;
30
+ latencyMs?: number;
31
+ }): ExtractedUsage | null;
32
+ /** @internal Wrap `proto[method]` so its resolved return value is recorded.
33
+ * Idempotent and fully guarded. Exported for tests. */
34
+ export declare function patchPrototype(proto: Any, method: string, provider: string): boolean;
35
+ /** Auto-capture LLM usage from installed providers. Pass e.g. `["openai"]` to limit
36
+ * which are patched; default patches all supported providers that are installed.
37
+ * Returns the list actually instrumented. Safe to call more than once. */
38
+ export declare function instrument(providers?: string[]): Promise<string[]>;
39
+ /** Restore all patched methods (primarily for tests). */
40
+ export declare function uninstrument(): void;
41
+ export {};