pyyol 1.10.1 → 1.12.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/dist/movetools.js CHANGED
@@ -92,9 +92,32 @@ const SCHEMAS = {
92
92
  },
93
93
  property: {
94
94
  type: "integer",
95
- description: "Board index of the property this action concerns, or 0.",
95
+ description: "Board index of the property this action concerns, or 0. On a bid during a " +
96
+ "HOUSING SHORTAGE auction this is the square you would put the piece on.",
96
97
  },
97
98
  amount: { type: "integer", description: "Coin amount this action carries, or 0." },
99
+ // The trade payload. OPTIONAL and NOT part of the canonical bound form — a trade binds
100
+ // on its verb alone (a nested structure re-rendered cosmetically differently would
101
+ // reject an honest turn), so nothing here can cost a turn its binding. Without it a
102
+ // bound agent could act but never DEAL, which is most of Monopoly.
103
+ trade: {
104
+ type: "object",
105
+ description: "Required to propose or counter a trade. Ignored for other actions.",
106
+ properties: {
107
+ target: {
108
+ type: "integer",
109
+ description: "Seat to offer to, or -1 to offer to the WHOLE TABLE (any player who can " +
110
+ "satisfy it may take it). Never 0 for 'everyone' — seat 0 is a real player.",
111
+ },
112
+ give_props: { type: "array", items: { type: "integer" }, description: "Squares you give." },
113
+ give_cash: { type: "integer", description: "Cash you give." },
114
+ give_cards: { type: "integer", description: "Get-out-of-jail-free cards you give." },
115
+ want_props: { type: "array", items: { type: "integer" }, description: "Squares you want." },
116
+ want_cash: { type: "integer", description: "Cash you want." },
117
+ want_cards: { type: "integer", description: "Get-out-of-jail-free cards you want." },
118
+ },
119
+ required: ["target"],
120
+ },
98
121
  },
99
122
  required: ["kind"],
100
123
  },
@@ -183,6 +206,17 @@ export function moveTool(game, provider = "openai", planRounds) {
183
206
  * Worth using. Without it a model may answer in prose, and a turn with no tool call is
184
207
  * unverified — the agent keeps playing but earns no completion binding.
185
208
  */
209
+ /**
210
+ * The tool_choice value that FORCES the model to answer with the move tool.
211
+ *
212
+ * NOT every model accepts forcing. Some advertise tool support and still reject a
213
+ * required/named tool_choice — observed live: OpenRouter's `openai/gpt-oss-20b:free` answers
214
+ * `inference-enforced tool_choice (required/named) is not supported`, HTTP 400, on every call.
215
+ *
216
+ * If you see that, send `"auto"` instead. Binding reads the RESPONSE, so forcing is only a way
217
+ * to raise the hit rate — a model that emits the tool call on its own binds exactly the same.
218
+ * Forcing is the default because most models take it and it wastes fewer turns.
219
+ */
186
220
  export function moveToolChoice(game, provider = "openai") {
187
221
  const name = moveToolName(game);
188
222
  if (!name)
package/dist/runtime.d.ts CHANGED
@@ -1,19 +1,3 @@
1
- /**
2
- * The Pyyol Runtime Connector — the local-runtime transport for JS/TS.
3
- *
4
- * Your agent runs on your own machine and dials OUT over a single persistent
5
- * WebSocket to the platform. The platform pushes match lifecycle down that socket
6
- * and reads decisions back over it, so a laptop behind NAT works with zero
7
- * networking config — you never host an inbound endpoint. This is the Beta model.
8
- *
9
- * The connector owns registration, heartbeats, automatic reconnection with
10
- * exponential backoff, and request/response correlation; it dispatches frames to
11
- * the handlers you registered on your `Agent`. No game logic lives here.
12
- *
13
- * const agent = new Agent({ supportedGames: ["goofspiel"], name: "OlympAI" });
14
- * agent.onTurn("goofspiel", (v) => ({ round: v.round, card: Math.max(...v.legal_actions) }));
15
- * await agent.run({ url: "wss://pyyol.example/v1/agent/connect", agentId: "ag_…", token: "…" });
16
- */
17
1
  import type { Agent } from "./server.js";
18
2
  export declare const PROTOCOL_VERSION = "1.0";
19
3
  /** A minimal structural type for a WHATWG WebSocket (Node 22+ global, or `ws`). */
@@ -75,6 +59,7 @@ export declare class RuntimeConnector {
75
59
  private registered;
76
60
  private refreshAttempts;
77
61
  private turnNo;
62
+ private watchOffered;
78
63
  private readonly tracer;
79
64
  constructor(agent: Agent, opts: RuntimeOptions);
80
65
  /** The gateway echoes the newest published version on the registered frame.
@@ -84,6 +69,23 @@ export declare class RuntimeConnector {
84
69
  run(): Promise<void>;
85
70
  stop(): void;
86
71
  /** Emit a live-feed line to the CLI/console, if a sink was provided. */
72
+ /**
73
+ * Offer "browser or terminal?" when a match is found, without blocking anything.
74
+ *
75
+ * `pyyol run` is the path a developer is actually on when they type a command and a
76
+ * staked match appears, and until now that match simply began — no choice, and no way
77
+ * to reach the live table except finding it yourself.
78
+ *
79
+ * Never awaited by the caller. The prompt waits up to ten seconds for a keystroke, and
80
+ * the dispatch loop cannot afford that: the heartbeat that keeps the connection alive
81
+ * and the first turn of the match are both queued behind it. A developer who stepped
82
+ * away for coffee would come back to a forfeited stake.
83
+ *
84
+ * Opt out with PYYOL_WATCH=terminal (or browser to skip straight to opening it).
85
+ * Anything non-interactive is already handled inside askWatch, which prints nothing at
86
+ * all without a TTY on both ends.
87
+ */
88
+ private offerWatch;
87
89
  private feed;
88
90
  /** Warn (once) when connecting over cleartext ws:// to a non-local host — the
89
91
  * register token is sent in the clear (mirrors the Python connector). */
package/dist/runtime.js CHANGED
@@ -1,5 +1,23 @@
1
+ /**
2
+ * The Pyyol Runtime Connector — the local-runtime transport for JS/TS.
3
+ *
4
+ * Your agent runs on your own machine and dials OUT over a single persistent
5
+ * WebSocket to the platform. The platform pushes match lifecycle down that socket
6
+ * and reads decisions back over it, so a laptop behind NAT works with zero
7
+ * networking config — you never host an inbound endpoint. This is the Beta model.
8
+ *
9
+ * The connector owns registration, heartbeats, automatic reconnection with
10
+ * exponential backoff, and request/response correlation; it dispatches frames to
11
+ * the handlers you registered on your `Agent`. No game logic lives here.
12
+ *
13
+ * const agent = new Agent({ supportedGames: ["goofspiel"], name: "OlympAI" });
14
+ * agent.onTurn("goofspiel", (v) => ({ round: v.round, card: Math.max(...v.legal_actions) }));
15
+ * await agent.run({ url: "wss://pyyol.example/v1/agent/connect", agentId: "ag_…", token: "…" });
16
+ */
17
+ import { spawn } from "node:child_process";
1
18
  import { SDK_VERSION } from "./server.js";
2
19
  import { Tracer, runTurnUsage } from "./telemetry.js";
20
+ import { askWatch, watchUrl, WATCH_BROWSER, WATCH_TERMINAL } from "./watch.js";
3
21
  // Frame types — byte-identical to the Go gateway (internal/agentgw/frame.go).
4
22
  const HELLO = "hello", REGISTERED = "registered", PONG = "pong";
5
23
  const INITIALIZE = "initialize", TURN = "turn", EVENT = "event", GAME_END = "game_end", ERROR = "error";
@@ -89,6 +107,10 @@ export class RuntimeConnector {
89
107
  registered = false; // true once this session's register succeeded
90
108
  refreshAttempts = 0; // per-connection guard against a refresh loop
91
109
  turnNo = 0; // monotonic per-connection turn counter (telemetry attribution fallback)
110
+ // The watch prompt is offered ONCE per connection, not once per match: being asked
111
+ // before every match of a long run is the thing you learn to dread, and a timed-out
112
+ // prompt has left a reader on stdin that would swallow the next one.
113
+ watchOffered = false;
92
114
  // Opt-in Pyyol Lens telemetry (no-op unless PYYOL_LENS_ENDPOINT+KEY set).
93
115
  // Correlated to the match trace so the agent's model/tool calls render with
94
116
  // the platform's authoritative gateway spans.
@@ -145,6 +167,48 @@ export class RuntimeConnector {
145
167
  void this.tracer.close();
146
168
  }
147
169
  /** Emit a live-feed line to the CLI/console, if a sink was provided. */
170
+ /**
171
+ * Offer "browser or terminal?" when a match is found, without blocking anything.
172
+ *
173
+ * `pyyol run` is the path a developer is actually on when they type a command and a
174
+ * staked match appears, and until now that match simply began — no choice, and no way
175
+ * to reach the live table except finding it yourself.
176
+ *
177
+ * Never awaited by the caller. The prompt waits up to ten seconds for a keystroke, and
178
+ * the dispatch loop cannot afford that: the heartbeat that keeps the connection alive
179
+ * and the first turn of the match are both queued behind it. A developer who stepped
180
+ * away for coffee would come back to a forfeited stake.
181
+ *
182
+ * Opt out with PYYOL_WATCH=terminal (or browser to skip straight to opening it).
183
+ * Anything non-interactive is already handled inside askWatch, which prints nothing at
184
+ * all without a TTY on both ends.
185
+ */
186
+ async offerWatch(payload) {
187
+ if (this.watchOffered)
188
+ return;
189
+ this.watchOffered = true;
190
+ try {
191
+ const matchId = String(payload.match_id ?? "");
192
+ const game = String(payload.game ?? "");
193
+ if (!matchId || !game)
194
+ return;
195
+ const url = watchUrl(game, matchId);
196
+ if (!url)
197
+ return; // no link rather than one onto someone else's match
198
+ const env = String(process.env.PYYOL_WATCH ?? "").trim().toLowerCase();
199
+ const preset = env === WATCH_BROWSER || env === WATCH_TERMINAL ? env : "";
200
+ // Bounded by the countdown: the platform starts play whether or not this was
201
+ // answered, so a longer wait asks about a decision that has already passed.
202
+ const choice = preset || (await askWatch(`${game} · ${matchId}`, url));
203
+ if (choice !== WATCH_BROWSER)
204
+ return;
205
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
206
+ spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
207
+ }
208
+ catch {
209
+ // Watching is a courtesy; the match is not. Nothing here may reach the run loop.
210
+ }
211
+ }
148
212
  feed(kind, detail) {
149
213
  this.opts.onFeed?.(kind, detail);
150
214
  }
@@ -384,6 +448,12 @@ export class RuntimeConnector {
384
448
  case INITIALIZE: {
385
449
  const ack = await this.agent.ackInitialize(frame.payload ?? {});
386
450
  send({ t: RESPONSE, id: frame.id ?? "", payload: ack });
451
+ // AFTER the ack, and deliberately NOT awaited. On the socket path the platform
452
+ // treats a delivered initialize frame as the acknowledgement, so anything before
453
+ // the ack delays the answer that keeps this seat in the match — and awaiting the
454
+ // prompt here would stall every frame queued behind it, including the heartbeat
455
+ // holding the connection open and the first turn of the match.
456
+ void this.offerWatch(frame.payload ?? {});
387
457
  break;
388
458
  }
389
459
  case EVENT:
@@ -391,7 +461,12 @@ export class RuntimeConnector {
391
461
  match_id: frame.match_id ?? "", game: frame.game ?? "",
392
462
  seq: frame.seq ?? 0, type: frame.kind ?? "", payload: frame.payload,
393
463
  });
394
- this.feed("event", `${frame.kind ?? "event"}${frame.seq !== undefined ? ` seq=${frame.seq}` : ""}`);
464
+ if (frame.kind === "match_start") {
465
+ this.feed("match_start", countdownLine(frame.payload));
466
+ }
467
+ else {
468
+ this.feed("event", `${frame.kind ?? "event"}${frame.seq !== undefined ? ` seq=${frame.seq}` : ""}`);
469
+ }
395
470
  break;
396
471
  case GAME_END:
397
472
  await this.agent.notifyGameEnd({
@@ -404,3 +479,43 @@ export class RuntimeConnector {
404
479
  }
405
480
  }
406
481
  }
482
+ /** How long until play begins, as a line for the terminal.
483
+ *
484
+ * Mirrors _countdown_line in the Python SDK, and must keep mirroring it: the two SDKs
485
+ * disagreeing about when a match starts is the same class of divergence sdk/conformance
486
+ * exists to prevent.
487
+ *
488
+ * The platform sends an ABSOLUTE `starts_at` and its own `server_now`, never a duration.
489
+ * Both are needed. The instant is what the browser counts to as well, so the two surfaces
490
+ * agree instead of each counting down from ten and drifting apart; `server_now` is what
491
+ * makes this line correct on a machine whose clock is wrong.
492
+ *
493
+ * So the remaining time is measured against the SERVER's clock:
494
+ *
495
+ * remaining = starts_at - server_now
496
+ *
497
+ * Reading Date.now() here would reintroduce exactly the skew that pair exists to remove — a
498
+ * developer whose laptop is two minutes fast would see a countdown that had already ended on
499
+ * a match that has not started.
500
+ *
501
+ * Fails soft to "match starting": a malformed timestamp must never stop an agent playing.
502
+ * The countdown is a courtesy, the match is not.
503
+ */
504
+ function countdownLine(payload) {
505
+ if (!payload || typeof payload !== "object")
506
+ return "match starting";
507
+ const p = payload;
508
+ const starts = parseTs(p.starts_at);
509
+ const now = parseTs(p.server_now);
510
+ if (starts === null || now === null)
511
+ return "match starting";
512
+ const secs = Math.max(0, Math.round((starts - now) / 1000));
513
+ return `match starts in ${secs}s`;
514
+ }
515
+ /** Parse an RFC3339 timestamp to epoch ms, or null. Go emits a trailing Z, which Date handles. */
516
+ function parseTs(v) {
517
+ if (typeof v !== "string" || !v)
518
+ return null;
519
+ const t = Date.parse(v);
520
+ return Number.isNaN(t) ? null : t;
521
+ }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "1.10.1";
1
+ export declare const SDK_VERSION = "1.12.0";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // GENERATED by scripts/genversion.mjs — do not edit by hand.
2
2
  // Source of truth is the "version" field in package.json.
3
- export const SDK_VERSION = "1.10.1"; // x-release-please-version
3
+ export const SDK_VERSION = "1.12.0"; // x-release-please-version
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The watch prompt: where a developer chooses to follow a match.
3
+ *
4
+ * Mirrors `pyyol/console.py::ask_watch` in the Python SDK. The two must behave
5
+ * identically — a developer who switches language should not discover that one of them
6
+ * hangs their CI and the other does not.
7
+ *
8
+ * A match used to open a browser tab on its own the moment it started. That is the wrong
9
+ * default in both directions: on a remote box or in tmux the tab goes nowhere, and a
10
+ * developer who ran a command in a terminal did not necessarily ask to have their screen
11
+ * taken over. So we ask, once, and remember.
12
+ */
13
+ export declare const WATCH_BROWSER = "browser";
14
+ export declare const WATCH_TERMINAL = "terminal";
15
+ export type WatchChoice = typeof WATCH_BROWSER | typeof WATCH_TERMINAL;
16
+ export interface AskWatchOpts {
17
+ /** Seconds before the default is taken. Must not outlive the start countdown. */
18
+ timeoutMs?: number;
19
+ stdin?: NodeJS.ReadableStream & {
20
+ isTTY?: boolean;
21
+ };
22
+ stdout?: NodeJS.WritableStream & {
23
+ isTTY?: boolean;
24
+ };
25
+ color?: boolean;
26
+ }
27
+ export declare const DEFAULT_DASHBOARD: string;
28
+ /** Browser URL for a specific live match, or "" when it cannot be named exactly. */
29
+ export declare function watchUrl(arena: string, matchId: string, dashboard?: string): string;
30
+ /**
31
+ * Ask where to watch this match.
32
+ *
33
+ * Three rules keep this from becoming a liability:
34
+ *
35
+ * **It never blocks a machine.** No TTY on stdin OR stdout means nobody is there to
36
+ * answer — CI, a pipe, a systemd unit — so it resolves to the terminal default without
37
+ * printing a prompt at all. A prompt that can hang a pipeline is worse than no prompt.
38
+ *
39
+ * **It never outlives the countdown.** The read is bounded and defaults on expiry. The
40
+ * match begins whether or not this question was answered, and a prompt still on screen
41
+ * after play started is asking about a decision that is already gone.
42
+ *
43
+ * **It never keeps the process alive.** The readline interface is closed and stdin is
44
+ * paused on every path, including the timeout. A lingering stdin listener would hold the
45
+ * event loop open and a finished `pyyol play` would simply never exit.
46
+ */
47
+ export declare function askWatch(label: string, url: string, opts?: AskWatchOpts): Promise<WatchChoice>;
package/dist/watch.js ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The watch prompt: where a developer chooses to follow a match.
3
+ *
4
+ * Mirrors `pyyol/console.py::ask_watch` in the Python SDK. The two must behave
5
+ * identically — a developer who switches language should not discover that one of them
6
+ * hangs their CI and the other does not.
7
+ *
8
+ * A match used to open a browser tab on its own the moment it started. That is the wrong
9
+ * default in both directions: on a remote box or in tmux the tab goes nowhere, and a
10
+ * developer who ran a command in a terminal did not necessarily ask to have their screen
11
+ * taken over. So we ask, once, and remember.
12
+ */
13
+ import { createInterface } from "node:readline";
14
+ export const WATCH_BROWSER = "browser";
15
+ export const WATCH_TERMINAL = "terminal";
16
+ // Stripping ANSI escapes is how a row's true on-screen width is measured.
17
+ // eslint-disable-next-line no-control-regex -- matching them is the point
18
+ const ANSI = /\x1b\[[0-9;]*m/g;
19
+ const visibleLen = (s) => s.replace(ANSI, "").length;
20
+ export const DEFAULT_DASHBOARD = (process.env.PYYOL_DASHBOARD || "").replace(/\/$/, "") || "https://pyyol.com";
21
+ /**
22
+ * Where a running match is watched in the browser, per game. Verified against the
23
+ * client's routes: Goofspiel and Monopoly take ?match= at the top level; Mafia's viewer
24
+ * lives under /arena. A wrong path is worse than no link — it lands the developer on a
25
+ * DIFFERENT live match and everything they see is someone else's game.
26
+ *
27
+ * Lives here rather than in cli.ts because the runtime needs it too, and the runtime
28
+ * cannot import the CLI (the CLI imports the runtime). One copy, so the two paths cannot
29
+ * drift into disagreeing about where a match is watched.
30
+ */
31
+ const WATCH_ROUTE = {
32
+ goofspiel: "/goofspiel",
33
+ mafia: "/arena/mafia",
34
+ monopoly: "/monopoly",
35
+ };
36
+ /** Browser URL for a specific live match, or "" when it cannot be named exactly. */
37
+ export function watchUrl(arena, matchId, dashboard = DEFAULT_DASHBOARD) {
38
+ const route = WATCH_ROUTE[arena];
39
+ if (!route || !matchId || !dashboard)
40
+ return "";
41
+ // encodeURIComponent (not encodeURI) so a slash is escaped too, and cannot alter the
42
+ // path instead of the query.
43
+ return `${dashboard.replace(/\/$/, "")}${route}?match=${encodeURIComponent(matchId)}`;
44
+ }
45
+ /**
46
+ * Ask where to watch this match.
47
+ *
48
+ * Three rules keep this from becoming a liability:
49
+ *
50
+ * **It never blocks a machine.** No TTY on stdin OR stdout means nobody is there to
51
+ * answer — CI, a pipe, a systemd unit — so it resolves to the terminal default without
52
+ * printing a prompt at all. A prompt that can hang a pipeline is worse than no prompt.
53
+ *
54
+ * **It never outlives the countdown.** The read is bounded and defaults on expiry. The
55
+ * match begins whether or not this question was answered, and a prompt still on screen
56
+ * after play started is asking about a decision that is already gone.
57
+ *
58
+ * **It never keeps the process alive.** The readline interface is closed and stdin is
59
+ * paused on every path, including the timeout. A lingering stdin listener would hold the
60
+ * event loop open and a finished `pyyol play` would simply never exit.
61
+ */
62
+ export async function askWatch(label, url, opts = {}) {
63
+ const timeoutMs = opts.timeoutMs ?? 10_000;
64
+ const stdin = opts.stdin ?? process.stdin;
65
+ const stdout = opts.stdout ?? process.stdout;
66
+ if (!stdin.isTTY || !stdout.isTTY)
67
+ return WATCH_TERMINAL;
68
+ const color = opts.color ?? process.env.NO_COLOR === undefined;
69
+ const c = (text, code) => (color ? `\x1b[${code}m${text}\x1b[0m` : text);
70
+ const rows = [
71
+ c(label, "36"),
72
+ "",
73
+ `${c("[b]", "1")} watch the live table in your browser`,
74
+ `${c("[t]", "1")} follow the logs here ${c("· default", "90")}`,
75
+ ];
76
+ // Sized to the content, measured on the UNCOLOURED text: an escape sequence takes
77
+ // columns in a string and none on screen, so padding by raw length draws a box that is
78
+ // crooked exactly when colour is on.
79
+ const width = Math.max(...rows.map(visibleLen)) + 2;
80
+ stdout.write("\n" + c("╭─ match found " + "─".repeat(Math.max(0, width - 13)) + "╮", "90") + "\n");
81
+ for (const r of rows) {
82
+ stdout.write(c("│", "90") + " " + r + " ".repeat(width - visibleLen(r)) + c("│", "90") + "\n");
83
+ }
84
+ stdout.write(c("╰" + "─".repeat(width + 1) + "╯", "90") + "\n");
85
+ if (url)
86
+ stdout.write(" " + c(url, "90") + "\n");
87
+ stdout.write(" " + c("›", "36") + " ");
88
+ const answer = await readLine(stdin, timeoutMs);
89
+ if (answer === null) {
90
+ // Say the default was taken. An unexplained newline reads as a dropped keystroke.
91
+ stdout.write("\n " + c(`no answer in ${Math.round(timeoutMs / 1000)}s — following here`, "90") + "\n");
92
+ return WATCH_TERMINAL;
93
+ }
94
+ return answer.trim().toLowerCase().startsWith("b") ? WATCH_BROWSER : WATCH_TERMINAL;
95
+ }
96
+ /** One line from stdin, or null if the timeout wins. Always tears the reader down. */
97
+ function readLine(stdin, timeoutMs) {
98
+ return new Promise((resolve) => {
99
+ const rl = createInterface({ input: stdin });
100
+ let done = false;
101
+ const finish = (v) => {
102
+ if (done)
103
+ return;
104
+ done = true;
105
+ clearTimeout(timer);
106
+ rl.close();
107
+ // pause(), or a resumed stdin keeps the event loop alive and the CLI never exits.
108
+ if (typeof stdin.pause === "function")
109
+ stdin.pause();
110
+ resolve(v);
111
+ };
112
+ // Deliberately NOT unref'd. An unref'd timer may never fire, and this timer firing
113
+ // is what RESOLVES the promise — unref turned "defaults after ten seconds" into
114
+ // "hangs forever" whenever nothing else kept the event loop alive. It is short and
115
+ // cleared on every answered path, so it cannot hold the process open for long.
116
+ const timer = setTimeout(() => finish(null), timeoutMs);
117
+ rl.once("line", (line) => finish(line));
118
+ // A closed stdin is a non-answer, not a crash mid-match.
119
+ rl.once("close", () => finish(null));
120
+ rl.once("error", () => finish(null));
121
+ });
122
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pyyol",
3
- "version": "1.10.1",
3
+ "version": "1.12.0",
4
4
  "description": "Official JS/TS SDK for pyyol — run AI game-playing agents locally over a WebSocket (Beta)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",