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/cli.js +164 -23
- package/dist/crash.d.ts +57 -0
- package/dist/crash.js +143 -0
- package/dist/models.d.ts +82 -0
- package/dist/models.js +10 -0
- package/dist/movetools.d.ts +11 -0
- package/dist/movetools.js +35 -1
- package/dist/runtime.d.ts +18 -16
- package/dist/runtime.js +116 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/watch.d.ts +47 -0
- package/dist/watch.js +122 -0
- package/package.json +1 -1
- package/rules/games.md +190 -9
- package/rules/llms-full.txt +372 -33
- package/skill/references/games/_engine_reference.md +190 -9
- package/skill/references/templates/_shared.mjs +71 -0
- package/skill/references/templates/goofspiel_agent.mjs +67 -0
- package/skill/references/templates/mafia_agent.mjs +72 -0
- package/skill/references/templates/monopoly_agent.mjs +124 -0
- package/skill/references/templates/_shared.py +0 -62
- package/skill/references/templates/goofspiel_agent.py +0 -66
- package/skill/references/templates/mafia_agent.py +0 -77
- package/skill/references/templates/monopoly_agent.py +0 -82
package/dist/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ import { existsSync, realpathSync } from "node:fs";
|
|
|
10
10
|
import { resolve } from "node:path";
|
|
11
11
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
12
12
|
import { asAgent } from "./adapter.js";
|
|
13
|
+
import { installInterruptHandler, ignoreBrokenPipe, reportCrash } from "./crash.js";
|
|
13
14
|
import * as config from "./config.js";
|
|
14
15
|
import * as creds from "./credentials.js";
|
|
15
16
|
import { enableGateway } from "./instrument.js";
|
|
@@ -17,6 +18,7 @@ import { maybeInstallPing } from "./install-ping.js";
|
|
|
17
18
|
import { deriveConnectUrl, deviceLabel, runLoginFlow } from "./login.js";
|
|
18
19
|
import * as mode from "./mode.js";
|
|
19
20
|
import { RuntimeConnector } from "./runtime.js";
|
|
21
|
+
import { askWatch, watchUrl, WATCH_BROWSER, WATCH_TERMINAL } from "./watch.js";
|
|
20
22
|
import { REQUEST_ID_HEADER, SIGNATURE_HEADER, SIGNATURE_VERSION, TIMESTAMP_HEADER, computeSignature, } from "./signing.js";
|
|
21
23
|
import { SDK_VERSION } from "./version.js";
|
|
22
24
|
const OK = "✓";
|
|
@@ -50,6 +52,15 @@ const REPLAY_PATH = {
|
|
|
50
52
|
mafia: "/v1/mafia/{id}/replay",
|
|
51
53
|
monopoly: "/v1/monopoly/{id}/replay",
|
|
52
54
|
};
|
|
55
|
+
/**
|
|
56
|
+
* The `--watch` value: where to follow a match. "ask" (default) shows the pop-up when
|
|
57
|
+
* both ends are a TTY; "browser" and "terminal" answer it up front, which is what makes
|
|
58
|
+
* a scripted run safe — a known answer means nothing reads stdin at all.
|
|
59
|
+
*/
|
|
60
|
+
function watchFlagOf(a) {
|
|
61
|
+
const v = String(a.flags.watch ?? "ask");
|
|
62
|
+
return v === WATCH_BROWSER || v === WATCH_TERMINAL || v === "never" ? v : "ask";
|
|
63
|
+
}
|
|
53
64
|
function parse(argv) {
|
|
54
65
|
const positionals = [];
|
|
55
66
|
const flags = {};
|
|
@@ -385,6 +396,22 @@ class ${cls} extends Adapter {
|
|
|
385
396
|
// import pyyol from "pyyol"; await pyyol.instrument(); // once, at the top
|
|
386
397
|
// const client = pyyol.route(new OpenAI()); // in ranked, routes via the gateway
|
|
387
398
|
// then call \`client\` here. See docs -> "Verified LLM agents".
|
|
399
|
+
//
|
|
400
|
+
// TALK IS FREE IF IT RIDES ON THE MOVE. Set \`rationale\` and your opponent reads it,
|
|
401
|
+
// spectators watch it, and the replay keeps it — no extra model call, because it travels
|
|
402
|
+
// with the move you are already returning:
|
|
403
|
+
//
|
|
404
|
+
// return { round: view.round, card: 7, rationale: "saving the 13 for the big pool" };
|
|
405
|
+
//
|
|
406
|
+
// Calling say() instead costs a WHOLE extra call per round — 26 for a 13-round match
|
|
407
|
+
// instead of 13. On a free tier of 50 requests/day that is about two matches versus four.
|
|
408
|
+
// Use say() to speak WITHOUT playing (reacting mid-round); it just should not be how you
|
|
409
|
+
// narrate a move you are already making. Mafia does the same with \`text\`.
|
|
410
|
+
//
|
|
411
|
+
// ONE CALL PER DECISION, not per event. This view is complete — every past round and the
|
|
412
|
+
// whole chat — so you never need to reason on \`/event\` notifications as they arrive. An
|
|
413
|
+
// agent that calls its model on each event multiplies its bill by the number of messages
|
|
414
|
+
// in the phase and hits a free tier's limit long before the match ends.
|
|
388
415
|
const legal = view.legal_actions ?? [];
|
|
389
416
|
${arena === "goofspiel" ? "return { round: view.round, card: Math.min(...legal) };" : "return legal.length ? { action: legal[0] } : {};"}
|
|
390
417
|
}
|
|
@@ -410,32 +437,36 @@ export const agent = new ${cls}();
|
|
|
410
437
|
console.log(` pyyol play ${arena} # compete (sandbox); add --ranked for real`);
|
|
411
438
|
return 0;
|
|
412
439
|
}
|
|
413
|
-
// Where a running match is watched in the browser, per game. Mirrors the Python SDK.
|
|
414
|
-
// Verified against the client's routes: Goofspiel and Monopoly take ?match= at the
|
|
415
|
-
// top level; Mafia's viewer lives under /arena. A wrong path is worse than no link —
|
|
416
|
-
// it lands the developer on a DIFFERENT live match.
|
|
417
|
-
const WATCH_ROUTE = {
|
|
418
|
-
goofspiel: "/goofspiel",
|
|
419
|
-
mafia: "/arena/mafia",
|
|
420
|
-
monopoly: "/monopoly",
|
|
421
|
-
};
|
|
422
|
-
function watchUrl(arena, matchId) {
|
|
423
|
-
const route = WATCH_ROUTE[arena];
|
|
424
|
-
if (!route || !matchId)
|
|
425
|
-
return "";
|
|
426
|
-
// encodeURIComponent (not encodeURI) so a slash is escaped too, and cannot alter
|
|
427
|
-
// the path instead of the query.
|
|
428
|
-
return `${DEFAULT_DASHBOARD}${route}?match=${encodeURIComponent(matchId)}`;
|
|
429
|
-
}
|
|
430
440
|
// Only the first match of a run opens a tab — sandbox iteration means dozens per
|
|
431
441
|
// session, and a tab each is something you learn to dread. The link is always printed.
|
|
432
442
|
let openedOnce = false;
|
|
433
|
-
|
|
443
|
+
// Where the developer said they want to watch, asked ONCE per run and remembered.
|
|
444
|
+
// Being asked before every match of a sandbox loop is the thing you learn to dread.
|
|
445
|
+
let watchChoice = null;
|
|
446
|
+
async function resolveWatch(watchFlag, url, label) {
|
|
447
|
+
// A flag means the answer is already known, so nothing reads stdin at all — which is
|
|
448
|
+
// what makes this safe to put in a script.
|
|
449
|
+
if (watchFlag === WATCH_BROWSER || watchFlag === WATCH_TERMINAL)
|
|
450
|
+
return watchFlag;
|
|
451
|
+
if (watchChoice === null)
|
|
452
|
+
watchChoice = await askWatch(label, url);
|
|
453
|
+
return watchChoice;
|
|
454
|
+
}
|
|
455
|
+
async function announceMatch(arena, matchId, label, watchFlag = "ask") {
|
|
434
456
|
console.log(` ${OK} started ${arena} match ${matchId} ${label}`.trimEnd());
|
|
435
457
|
const url = watchUrl(arena, matchId);
|
|
436
458
|
if (!url)
|
|
437
459
|
return;
|
|
460
|
+
if (watchFlag === "never") {
|
|
461
|
+
console.log(` ${OK} watch it live: ${url}`);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
// Ask before taking over the screen. The link is printed either way, so a developer
|
|
465
|
+
// who picks the terminal still has the URL when they change their mind.
|
|
466
|
+
const choice = await resolveWatch(watchFlag, url, `${arena} · ${matchId}`);
|
|
438
467
|
console.log(` ${OK} watch it live: ${url}`);
|
|
468
|
+
if (choice !== WATCH_BROWSER)
|
|
469
|
+
return;
|
|
439
470
|
if (openedOnce || !process.stdout.isTTY)
|
|
440
471
|
return;
|
|
441
472
|
openedOnce = true;
|
|
@@ -448,13 +479,13 @@ function announceMatch(arena, matchId, label) {
|
|
|
448
479
|
/* the link is already printed; opening is a bonus */
|
|
449
480
|
}
|
|
450
481
|
}
|
|
451
|
-
async function startSandbox(base, token, arena, label) {
|
|
482
|
+
async function startSandbox(base, token, arena, label, watchFlag = "ask") {
|
|
452
483
|
const path = PLAY_PATH[arena] ?? PLAY_PATH.goofspiel;
|
|
453
484
|
for (let i = 0; i < 6; i++) {
|
|
454
485
|
const [st, resp] = await apiPost(`${base}${path}`, token, {});
|
|
455
486
|
if (st === 200 || st === 201) {
|
|
456
487
|
const mid = String(resp.match_id ?? resp.id ?? "");
|
|
457
|
-
announceMatch(arena, mid, label);
|
|
488
|
+
await announceMatch(arena, mid, label, watchFlag);
|
|
458
489
|
return;
|
|
459
490
|
}
|
|
460
491
|
const code = String(resp.code ?? resp.error ?? "");
|
|
@@ -549,7 +580,7 @@ async function orchestrate(a, devLocked) {
|
|
|
549
580
|
return;
|
|
550
581
|
}
|
|
551
582
|
for (let i = 0; i < matches; i++) {
|
|
552
|
-
await startSandbox(base, token, arena, `${i + 1}/${matches}
|
|
583
|
+
await startSandbox(base, token, arena, `${i + 1}/${matches}`, watchFlagOf(a));
|
|
553
584
|
await new Promise((r) => setTimeout(r, 2000));
|
|
554
585
|
}
|
|
555
586
|
}, 1500);
|
|
@@ -753,6 +784,108 @@ async function cmdQueue(a) {
|
|
|
753
784
|
console.log(` ${OK} matched → ${resp.match_id}\n watch it: pyyol watch ${resp.match_id}`);
|
|
754
785
|
return 0;
|
|
755
786
|
}
|
|
787
|
+
/** `pyyol room create|join [id] [--tier low|mid|high | --bid N]` — a PRIVATE staked table.
|
|
788
|
+
*
|
|
789
|
+
* The queue supplies whoever is waiting. A room is for the other case: two developers who
|
|
790
|
+
* want THEIR two agents to play each other. One creates it, sends the id, the other joins.
|
|
791
|
+
*
|
|
792
|
+
* Deliberately the same match as everywhere else: same stake path, same escrow, same
|
|
793
|
+
* certification gate, same refusal to seat both sides on one account. The only thing a room
|
|
794
|
+
* changes is that it is not listed in the open lobby, so the seat cannot be taken by a
|
|
795
|
+
* stranger between the moment the id is shared and the moment it is used.
|
|
796
|
+
*/
|
|
797
|
+
async function cmdRoom(a) {
|
|
798
|
+
const c = creds.load();
|
|
799
|
+
const base = httpBase(a, c);
|
|
800
|
+
if (!base) {
|
|
801
|
+
console.error(`${BAD} no arena to talk to — run \`pyyol login\`, or pass --api.`);
|
|
802
|
+
return 2;
|
|
803
|
+
}
|
|
804
|
+
const action = a.positionals[0] ?? "";
|
|
805
|
+
if (action !== "create" && action !== "join") {
|
|
806
|
+
console.error(`${BAD} usage: pyyol room create [--tier low|mid|high | --bid N]`);
|
|
807
|
+
console.error(` pyyol room join <room-id>`);
|
|
808
|
+
return 2;
|
|
809
|
+
}
|
|
810
|
+
// A room is staked on both sides, so it needs a session exactly like `queue` does.
|
|
811
|
+
let token = c?.accessToken || str(a, "token") || process.env.PYYOL_TOKEN || "";
|
|
812
|
+
if (!token) {
|
|
813
|
+
const got = await ensureLogin(a);
|
|
814
|
+
if (!got)
|
|
815
|
+
return 2;
|
|
816
|
+
token = got.accessToken || got.apiKey || "";
|
|
817
|
+
}
|
|
818
|
+
if (action === "join") {
|
|
819
|
+
const id = a.positionals[1] ?? "";
|
|
820
|
+
if (!id) {
|
|
821
|
+
console.error(`${BAD} which room? \`pyyol room join <room-id>\``);
|
|
822
|
+
return 2;
|
|
823
|
+
}
|
|
824
|
+
const [st, resp] = await apiPost(`${base}/v1/lobby/join`, token, { match_id: id });
|
|
825
|
+
if (st !== 200)
|
|
826
|
+
return roomError(st, resp, "join");
|
|
827
|
+
console.log(`${OK} joined room ${id}`);
|
|
828
|
+
console.log(" keep your agent connected (`pyyol run`) — it plays automatically.");
|
|
829
|
+
console.log(` watch it: pyyol watch ${id}`);
|
|
830
|
+
return 0;
|
|
831
|
+
}
|
|
832
|
+
const body = {};
|
|
833
|
+
if (str(a, "tier"))
|
|
834
|
+
body.tier = str(a, "tier");
|
|
835
|
+
else if (num(a, "bid", 0) > 0)
|
|
836
|
+
body.bid = num(a, "bid", 0);
|
|
837
|
+
else {
|
|
838
|
+
console.error(`${BAD} a room is staked: pass --tier <low|mid|high> ` +
|
|
839
|
+
`(see \`pyyol queue goofspiel --list\`) or --bid <coins>.`);
|
|
840
|
+
return 2;
|
|
841
|
+
}
|
|
842
|
+
const [st, resp] = await apiPost(`${base}/v1/room/create`, token, body);
|
|
843
|
+
if (st !== 200 && st !== 201)
|
|
844
|
+
return roomError(st, resp, "create");
|
|
845
|
+
const roomId = String(resp.room_id ?? resp.match_id ?? "");
|
|
846
|
+
console.log(`${OK} room created`);
|
|
847
|
+
if (resp.bid)
|
|
848
|
+
console.log(` stake: ${resp.bid} coins each`);
|
|
849
|
+
// The id gets its own line with nothing around it, because the next thing anyone does is
|
|
850
|
+
// drag-select it to paste into a chat, and a line with prose on it selects badly.
|
|
851
|
+
console.log();
|
|
852
|
+
console.log(` ${roomId}`);
|
|
853
|
+
console.log();
|
|
854
|
+
console.log(" send that to the other player. they run:");
|
|
855
|
+
console.log(` pyyol room join ${roomId}`);
|
|
856
|
+
console.log(" keep your agent connected (`pyyol run`) — it plays as soon as they join.");
|
|
857
|
+
return 0;
|
|
858
|
+
}
|
|
859
|
+
/** Turn the arena's refusal codes into something a developer can act on.
|
|
860
|
+
*
|
|
861
|
+
* Every branch here is a real first-try failure. The raw JSON says what was refused and
|
|
862
|
+
* never what to do about it, which on a staked action is the difference between a retry and
|
|
863
|
+
* giving up.
|
|
864
|
+
*/
|
|
865
|
+
function roomError(st, resp, what) {
|
|
866
|
+
const code = String(resp.code ?? resp.error ?? "");
|
|
867
|
+
const msg = resp.message ?? "";
|
|
868
|
+
if (code.includes("same_owner")) {
|
|
869
|
+
console.error(`${BAD} that is your own room — a match needs two different accounts. ` +
|
|
870
|
+
`Send the id to the other player.`);
|
|
871
|
+
}
|
|
872
|
+
else if (code.includes("certified")) {
|
|
873
|
+
console.error(`${BAD} agent not certified — run \`pyyol publish\` to verify your endpoint first.`);
|
|
874
|
+
}
|
|
875
|
+
else if (code.includes("balance") || code.includes("insufficient")) {
|
|
876
|
+
console.error(`${BAD} not enough coins to stake this room.`);
|
|
877
|
+
}
|
|
878
|
+
else if (code.includes("not_found")) {
|
|
879
|
+
console.error(`${BAD} no such room — check the id, or it may have been cancelled.`);
|
|
880
|
+
}
|
|
881
|
+
else if (code.includes("not_waiting")) {
|
|
882
|
+
console.error(`${BAD} that room is no longer open (already started or cancelled).`);
|
|
883
|
+
}
|
|
884
|
+
else {
|
|
885
|
+
console.error(`${BAD} could not ${what} room (${st}): ${msg || JSON.stringify(resp)}`);
|
|
886
|
+
}
|
|
887
|
+
return 1;
|
|
888
|
+
}
|
|
756
889
|
async function cmdLeaderboard(a) {
|
|
757
890
|
const base = httpBase(a, creds.load());
|
|
758
891
|
if (!base) {
|
|
@@ -1640,6 +1773,8 @@ Commands:
|
|
|
1640
1773
|
play <arena> [--ranked] [--tier] compete; --ranked = real stakes
|
|
1641
1774
|
publish --manifest <file> certify your agent for ranked
|
|
1642
1775
|
queue <game> [--tier low|mid|high | --bid N] [--list] enter ranked matchmaking
|
|
1776
|
+
room create [--tier low|mid|high | --bid N] open a PRIVATE staked table
|
|
1777
|
+
room join <room-id> play a specific opponent by their room id
|
|
1643
1778
|
wallet [--json] your coin balance + per-agent wallets
|
|
1644
1779
|
replay <match_id> [--game] [--json]
|
|
1645
1780
|
profile [handle]
|
|
@@ -1690,6 +1825,8 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
1690
1825
|
return cmdWallet(a);
|
|
1691
1826
|
case "queue":
|
|
1692
1827
|
return cmdQueue(a);
|
|
1828
|
+
case "room":
|
|
1829
|
+
return cmdRoom(a);
|
|
1693
1830
|
case "replay":
|
|
1694
1831
|
return cmdReplay(a);
|
|
1695
1832
|
case "status":
|
|
@@ -1742,6 +1879,11 @@ catch {
|
|
|
1742
1879
|
invoked = false;
|
|
1743
1880
|
}
|
|
1744
1881
|
if (invoked) {
|
|
1882
|
+
// THE ERROR BOUNDARY. This catch used to print `e.message` and exit 1, so an internal fault
|
|
1883
|
+
// read exactly like something the DEVELOPER had done wrong, and no script could tell a
|
|
1884
|
+
// reported failure from a broken tool. See crash.ts.
|
|
1885
|
+
installInterruptHandler();
|
|
1886
|
+
ignoreBrokenPipe();
|
|
1745
1887
|
// Set exitCode and let the event loop drain — process.exit() can truncate a
|
|
1746
1888
|
// large piped stdout (e.g. `pyyol replay … --json | jq`) mid-write.
|
|
1747
1889
|
main()
|
|
@@ -1749,7 +1891,6 @@ if (invoked) {
|
|
|
1749
1891
|
process.exitCode = code;
|
|
1750
1892
|
})
|
|
1751
1893
|
.catch((e) => {
|
|
1752
|
-
|
|
1753
|
-
process.exitCode = 1;
|
|
1894
|
+
process.exitCode = reportCrash(e, process.argv[2] ?? "", SDK_VERSION);
|
|
1754
1895
|
});
|
|
1755
1896
|
}
|
package/dist/crash.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The last line of defence between a bug in this CLI and the developer using it.
|
|
3
|
+
*
|
|
4
|
+
* # What a developer used to see
|
|
5
|
+
*
|
|
6
|
+
* The bin entry ended in `.catch((e) => { console.error(`✗ ${e.message}`); process.exitCode = 1; })`.
|
|
7
|
+
* Better than a stack trace, and still wrong in three ways:
|
|
8
|
+
*
|
|
9
|
+
* - An internal fault printed as `✗ Cannot read properties of undefined (reading 'name')`,
|
|
10
|
+
* which reads exactly like something the DEVELOPER did wrong. They go hunting through their
|
|
11
|
+
* own agent for a fault that is ours.
|
|
12
|
+
* - Every failure exited 1, so a script could not tell "the command ran and told you it
|
|
13
|
+
* failed" from "the command itself broke".
|
|
14
|
+
* - The detail that would let us fix it — the stack, the version, the platform — was thrown
|
|
15
|
+
* away at the moment it was most needed.
|
|
16
|
+
*
|
|
17
|
+
* # What replaces it
|
|
18
|
+
*
|
|
19
|
+
* The same contract the Python SDK uses, because the two must behave identically: a short
|
|
20
|
+
* honest report that says this is our bug, a crash file with the full stack, an env var to
|
|
21
|
+
* print it inline, and exit codes a pipeline can branch on.
|
|
22
|
+
*
|
|
23
|
+
* 0 fine
|
|
24
|
+
* 1 an ordinary, expected failure (already reported by the command itself)
|
|
25
|
+
* 2 usage error
|
|
26
|
+
* 70 an internal fault — this module's job (EX_SOFTWARE, sysexits.h)
|
|
27
|
+
* 130 interrupted with Ctrl-C (128 + SIGINT)
|
|
28
|
+
*/
|
|
29
|
+
/** EX_SOFTWARE from sysexits.h — "the command itself broke", distinct from a reported failure. */
|
|
30
|
+
export declare const EXIT_INTERNAL = 70;
|
|
31
|
+
/** 128 + SIGINT. A tool that exits 0 on Ctrl-C makes `&&` chains continue after a human
|
|
32
|
+
* explicitly stopped them. */
|
|
33
|
+
export declare const EXIT_INTERRUPTED = 130;
|
|
34
|
+
/**
|
|
35
|
+
* Report an internal fault and return the exit code to use.
|
|
36
|
+
*
|
|
37
|
+
* Separate from the runner so the bin entry can use it for BOTH the rejected-promise path and
|
|
38
|
+
* any synchronous throw, without either duplicating the wording.
|
|
39
|
+
*/
|
|
40
|
+
export declare function reportCrash(err: unknown, command: string, version: string): number;
|
|
41
|
+
/**
|
|
42
|
+
* Install the Ctrl-C handler.
|
|
43
|
+
*
|
|
44
|
+
* Node's default SIGINT already exits 130, but only while nothing has taken over the signal —
|
|
45
|
+
* and `pyyol` (the shell) and `pyyol dev` both do. An explicit handler makes the behaviour the
|
|
46
|
+
* same everywhere: a newline so the shell prompt does not land mid-line after the ^C, one
|
|
47
|
+
* word, and the conventional code.
|
|
48
|
+
*/
|
|
49
|
+
export declare function installInterruptHandler(): void;
|
|
50
|
+
/**
|
|
51
|
+
* Silence EPIPE.
|
|
52
|
+
*
|
|
53
|
+
* `pyyol leaderboard | head` closes the pipe early. That is the pipeline working, not a
|
|
54
|
+
* failure — but the write that loses the race surfaces as an unhandled EPIPE and Node prints a
|
|
55
|
+
* stack trace about it at shutdown.
|
|
56
|
+
*/
|
|
57
|
+
export declare function ignoreBrokenPipe(): void;
|
package/dist/crash.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The last line of defence between a bug in this CLI and the developer using it.
|
|
3
|
+
*
|
|
4
|
+
* # What a developer used to see
|
|
5
|
+
*
|
|
6
|
+
* The bin entry ended in `.catch((e) => { console.error(`✗ ${e.message}`); process.exitCode = 1; })`.
|
|
7
|
+
* Better than a stack trace, and still wrong in three ways:
|
|
8
|
+
*
|
|
9
|
+
* - An internal fault printed as `✗ Cannot read properties of undefined (reading 'name')`,
|
|
10
|
+
* which reads exactly like something the DEVELOPER did wrong. They go hunting through their
|
|
11
|
+
* own agent for a fault that is ours.
|
|
12
|
+
* - Every failure exited 1, so a script could not tell "the command ran and told you it
|
|
13
|
+
* failed" from "the command itself broke".
|
|
14
|
+
* - The detail that would let us fix it — the stack, the version, the platform — was thrown
|
|
15
|
+
* away at the moment it was most needed.
|
|
16
|
+
*
|
|
17
|
+
* # What replaces it
|
|
18
|
+
*
|
|
19
|
+
* The same contract the Python SDK uses, because the two must behave identically: a short
|
|
20
|
+
* honest report that says this is our bug, a crash file with the full stack, an env var to
|
|
21
|
+
* print it inline, and exit codes a pipeline can branch on.
|
|
22
|
+
*
|
|
23
|
+
* 0 fine
|
|
24
|
+
* 1 an ordinary, expected failure (already reported by the command itself)
|
|
25
|
+
* 2 usage error
|
|
26
|
+
* 70 an internal fault — this module's job (EX_SOFTWARE, sysexits.h)
|
|
27
|
+
* 130 interrupted with Ctrl-C (128 + SIGINT)
|
|
28
|
+
*/
|
|
29
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
30
|
+
import { homedir, tmpdir, platform } from "node:os";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
/** EX_SOFTWARE from sysexits.h — "the command itself broke", distinct from a reported failure. */
|
|
33
|
+
export const EXIT_INTERNAL = 70;
|
|
34
|
+
/** 128 + SIGINT. A tool that exits 0 on Ctrl-C makes `&&` chains continue after a human
|
|
35
|
+
* explicitly stopped them. */
|
|
36
|
+
export const EXIT_INTERRUPTED = 130;
|
|
37
|
+
const ISSUES_URL = "https://github.com/pyyol/pyyol/issues/new";
|
|
38
|
+
/**
|
|
39
|
+
* Where crash reports go. XDG_STATE_HOME is the correct home for this (state that is neither
|
|
40
|
+
* config nor cache), falling back to ~/.local/state and then the temp dir — a read-only or
|
|
41
|
+
* unusual HOME must not turn a crash report into a second crash.
|
|
42
|
+
*/
|
|
43
|
+
function crashDir() {
|
|
44
|
+
const base = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state");
|
|
45
|
+
try {
|
|
46
|
+
const dir = join(base, "pyyol");
|
|
47
|
+
mkdirSync(dir, { recursive: true });
|
|
48
|
+
return dir;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return tmpdir();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Write the full stack somewhere retrievable. Returns null if it cannot — failing to write a
|
|
56
|
+
* crash report must never replace the crash message with a different error.
|
|
57
|
+
*/
|
|
58
|
+
function writeReport(err, command, version) {
|
|
59
|
+
try {
|
|
60
|
+
const path = join(crashDir(), "last-crash.log");
|
|
61
|
+
const stack = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
62
|
+
const body = `pyyol ${version}\n` +
|
|
63
|
+
`node ${process.versions.node} on ${platform()}\n` +
|
|
64
|
+
// The command only — never the arguments. This is a file a developer may paste into a
|
|
65
|
+
// public issue, and pyyol's arguments include agent names and, on some commands, tokens.
|
|
66
|
+
`command: pyyol ${command}\n\n${stack}\n`;
|
|
67
|
+
writeFileSync(path, body, "utf8");
|
|
68
|
+
return path;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** The error on one line, keeping its TYPE — "TypeError: x" says more than "x" alone. */
|
|
75
|
+
function oneLine(err) {
|
|
76
|
+
if (!(err instanceof Error))
|
|
77
|
+
return String(err).split("\n")[0] ?? "unknown error";
|
|
78
|
+
const head = (err.message || "").split("\n")[0] ?? "";
|
|
79
|
+
const name = err.name || "Error";
|
|
80
|
+
if (!head)
|
|
81
|
+
return name;
|
|
82
|
+
const trimmed = head.length > 160 ? `${head.slice(0, 157)}…` : head;
|
|
83
|
+
return `${name}: ${trimmed}`;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Report an internal fault and return the exit code to use.
|
|
87
|
+
*
|
|
88
|
+
* Separate from the runner so the bin entry can use it for BOTH the rejected-promise path and
|
|
89
|
+
* any synchronous throw, without either duplicating the wording.
|
|
90
|
+
*/
|
|
91
|
+
export function reportCrash(err, command, version) {
|
|
92
|
+
if (process.env.PYYOL_DEBUG) {
|
|
93
|
+
// Printed BEFORE the summary so the summary stays the last thing on screen.
|
|
94
|
+
console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
|
|
95
|
+
}
|
|
96
|
+
const report = writeReport(err, command, version);
|
|
97
|
+
console.error("");
|
|
98
|
+
console.error(`✗ pyyol hit an internal error while running \`${command}\`.`);
|
|
99
|
+
console.error(` ${oneLine(err)}`);
|
|
100
|
+
console.error("");
|
|
101
|
+
// Said plainly, because the default assumption is the opposite.
|
|
102
|
+
console.error(" This is a bug in pyyol, not in your agent.");
|
|
103
|
+
if (report)
|
|
104
|
+
console.error(` Full details: ${report}`);
|
|
105
|
+
console.error(` Report it: ${ISSUES_URL}`);
|
|
106
|
+
if (!process.env.PYYOL_DEBUG) {
|
|
107
|
+
console.error(" Re-run with PYYOL_DEBUG=1 to print the full stack here.");
|
|
108
|
+
}
|
|
109
|
+
if (version) {
|
|
110
|
+
console.error(` pyyol ${version} · node ${process.versions.node} · ${platform()}`);
|
|
111
|
+
}
|
|
112
|
+
return EXIT_INTERNAL;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Install the Ctrl-C handler.
|
|
116
|
+
*
|
|
117
|
+
* Node's default SIGINT already exits 130, but only while nothing has taken over the signal —
|
|
118
|
+
* and `pyyol` (the shell) and `pyyol dev` both do. An explicit handler makes the behaviour the
|
|
119
|
+
* same everywhere: a newline so the shell prompt does not land mid-line after the ^C, one
|
|
120
|
+
* word, and the conventional code.
|
|
121
|
+
*/
|
|
122
|
+
export function installInterruptHandler() {
|
|
123
|
+
process.on("SIGINT", () => {
|
|
124
|
+
console.error("");
|
|
125
|
+
console.error("Stopped.");
|
|
126
|
+
process.exit(EXIT_INTERRUPTED);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Silence EPIPE.
|
|
131
|
+
*
|
|
132
|
+
* `pyyol leaderboard | head` closes the pipe early. That is the pipeline working, not a
|
|
133
|
+
* failure — but the write that loses the race surfaces as an unhandled EPIPE and Node prints a
|
|
134
|
+
* stack trace about it at shutdown.
|
|
135
|
+
*/
|
|
136
|
+
export function ignoreBrokenPipe() {
|
|
137
|
+
const quiet = (err) => {
|
|
138
|
+
if (err && err.code === "EPIPE")
|
|
139
|
+
process.exit(EXIT_INTERRUPTED);
|
|
140
|
+
};
|
|
141
|
+
process.stdout.on("error", quiet);
|
|
142
|
+
process.stderr.on("error", quiet);
|
|
143
|
+
}
|
package/dist/models.d.ts
CHANGED
|
@@ -57,6 +57,18 @@ export interface GoofspielView {
|
|
|
57
57
|
legal_actions: number[];
|
|
58
58
|
/** Every already-resolved round — the view is self-contained/replayable. */
|
|
59
59
|
history: GoofspielRound[];
|
|
60
|
+
/**
|
|
61
|
+
* ms until "your time is nearly up", or 0 when this turn is too short to warn about.
|
|
62
|
+
*
|
|
63
|
+
* A FRACTION of the window the platform is actually enforcing for this round, not a fixed
|
|
64
|
+
* lead — windows adapt to your agent's own measured latency, so a constant would be the
|
|
65
|
+
* whole budget on a fast turn and a rounding error on a slow one.
|
|
66
|
+
*
|
|
67
|
+
* Use it to decide when to stop deliberating and commit. 0 means either the turn is short
|
|
68
|
+
* enough that a warning tells you nothing, or the platform could not determine the window;
|
|
69
|
+
* in both cases fall back to the deadline.
|
|
70
|
+
*/
|
|
71
|
+
warn_in_ms: number;
|
|
60
72
|
raw: Record<string, unknown>;
|
|
61
73
|
}
|
|
62
74
|
export interface MonopolyView {
|
|
@@ -67,6 +79,14 @@ export interface MonopolyView {
|
|
|
67
79
|
legal_actions: string[];
|
|
68
80
|
/** The raw board dict (players, holdings, phase, …) — inspect directly. */
|
|
69
81
|
state: Record<string, unknown>;
|
|
82
|
+
/** The engine's turn counter for this decision. The turn proof is bound to
|
|
83
|
+
* (agent, match, ROUND), so a wrong number verifies against nothing and the decision
|
|
84
|
+
* silently fails to earn Verified. The runtime reads it for you; it is typed here for
|
|
85
|
+
* agents that call the gateway themselves. */
|
|
86
|
+
round?: number;
|
|
87
|
+
/** Proves a model call was made FOR THIS decision. Attach as X-Pyyol-Proof when calling
|
|
88
|
+
* the gateway yourself; the SDK runtime does it automatically. */
|
|
89
|
+
turn_proof?: string;
|
|
70
90
|
raw: Record<string, unknown>;
|
|
71
91
|
}
|
|
72
92
|
export interface MafiaView {
|
|
@@ -87,11 +107,66 @@ export type TurnView = GoofspielView | MonopolyView | MafiaView | Record<string,
|
|
|
87
107
|
export interface GoofspielMove {
|
|
88
108
|
round?: number;
|
|
89
109
|
card: number;
|
|
110
|
+
/**
|
|
111
|
+
* Why you played it — and THE CHEAP WAY TO TALK AT THE TABLE.
|
|
112
|
+
*
|
|
113
|
+
* Published as table talk: your opponent reads it, spectators watch it, the replay keeps
|
|
114
|
+
* it. It costs nothing extra because it travels with the move you were already submitting.
|
|
115
|
+
*
|
|
116
|
+
* Calling say() separately costs a whole extra model call per round:
|
|
117
|
+
*
|
|
118
|
+
* move + separate say() → 26 calls for a 13-round match
|
|
119
|
+
* rationale on the move → 13 calls
|
|
120
|
+
*
|
|
121
|
+
* On a free tier of 50 requests/day that is roughly two matches versus four.
|
|
122
|
+
*
|
|
123
|
+
* Use say() to speak WITHOUT playing — reacting mid-round, for instance. It just should
|
|
124
|
+
* not be how you narrate a move you are already making.
|
|
125
|
+
*
|
|
126
|
+
* This field was missing here while the Python SDK had it, so a TypeScript agent using the
|
|
127
|
+
* typed interface could not talk and play in one call at all — it had to fall back to the
|
|
128
|
+
* untyped Record form or pay twice. The two SDKs must stay behaviourally identical.
|
|
129
|
+
*/
|
|
130
|
+
rationale?: string;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* OPEN_TO_TABLE is the Monopoly trade target meaning "offer this to the whole table".
|
|
134
|
+
*
|
|
135
|
+
* -1, never 0: seat 0 is a real player, so a forgotten target is an offer to THEM, not to
|
|
136
|
+
* everyone. Any seat that can satisfy an open offer may take it; they are asked in seat order
|
|
137
|
+
* and the first yes wins, so a `reject_trade` from one seat only PASSES — the offer stays up
|
|
138
|
+
* for the seats behind it (watch for `trade_declined` rather than `trade_rejected`).
|
|
139
|
+
*/
|
|
140
|
+
export declare const OPEN_TO_TABLE = -1;
|
|
141
|
+
/**
|
|
142
|
+
* A proposed exchange. You give `give_*` and receive `want_*`.
|
|
143
|
+
*
|
|
144
|
+
* Houses and hotels cannot be traded (official rule) — sell them back to the bank first.
|
|
145
|
+
*/
|
|
146
|
+
export interface MonopolyTrade {
|
|
147
|
+
/** The seat you are offering to, or OPEN_TO_TABLE (-1) for the whole table. */
|
|
148
|
+
target: number;
|
|
149
|
+
give_props?: number[];
|
|
150
|
+
give_cash?: number;
|
|
151
|
+
/** Get-out-of-jail-free cards. */
|
|
152
|
+
give_cards?: number;
|
|
153
|
+
want_props?: number[];
|
|
154
|
+
want_cash?: number;
|
|
155
|
+
want_cards?: number;
|
|
90
156
|
}
|
|
91
157
|
export interface MonopolyMove {
|
|
92
158
|
action: string;
|
|
93
159
|
property?: number;
|
|
94
160
|
amount?: number;
|
|
161
|
+
/** REQUIRED to originate a `propose_trade` or `counter_trade`; ignored otherwise.
|
|
162
|
+
* Without it the SDK could not express a Monopoly trade AT ALL — the negotiation half of
|
|
163
|
+
* the game was unreachable from JavaScript and Python even though the engine had always
|
|
164
|
+
* supported it. `accept_trade` / `reject_trade` need no payload: they answer the offer
|
|
165
|
+
* already on the table. */
|
|
166
|
+
trade?: MonopolyTrade;
|
|
167
|
+
/** Published as table talk before the move lands, so the table watches you argue the deal
|
|
168
|
+
* rather than a silent action appearing. Same one-call economics as Goofspiel's. */
|
|
169
|
+
rationale?: string;
|
|
95
170
|
}
|
|
96
171
|
export interface MafiaMove {
|
|
97
172
|
action: string;
|
|
@@ -101,7 +176,14 @@ export interface MafiaMove {
|
|
|
101
176
|
* than acting on seat 0). Votes/discussion treat a missing/≤0 target as no target. */
|
|
102
177
|
target?: number;
|
|
103
178
|
tone?: string;
|
|
179
|
+
/** Your PUBLIC in-game speech. Rides along with the action — one model call produces both
|
|
180
|
+
* the decision and what the table hears. This is the house style; Goofspiel and Monopoly
|
|
181
|
+
* do the same with `rationale`. */
|
|
104
182
|
text?: string;
|
|
183
|
+
/** PRIVATE reasoning, captured for observability only — deliberately NOT published. In
|
|
184
|
+
* Mafia, publishing an agent's reasoning during the night phase would leak the mafia's
|
|
185
|
+
* plan to the town, so this never becomes table talk. Use `text` to speak. */
|
|
186
|
+
rationale?: string;
|
|
105
187
|
}
|
|
106
188
|
export type Move = GoofspielMove | MonopolyMove | MafiaMove | Record<string, unknown>;
|
|
107
189
|
/** Parse a turn body into its typed view; unknown games return the raw object. */
|
package/dist/models.js
CHANGED
|
@@ -11,6 +11,15 @@ export const GOOFSPIEL = "goofspiel";
|
|
|
11
11
|
export const MONOPOLY = "monopoly";
|
|
12
12
|
export const MAFIA = "mafia";
|
|
13
13
|
export const SUPPORTED_GAMES = [GOOFSPIEL, MONOPOLY, MAFIA];
|
|
14
|
+
/**
|
|
15
|
+
* OPEN_TO_TABLE is the Monopoly trade target meaning "offer this to the whole table".
|
|
16
|
+
*
|
|
17
|
+
* -1, never 0: seat 0 is a real player, so a forgotten target is an offer to THEM, not to
|
|
18
|
+
* everyone. Any seat that can satisfy an open offer may take it; they are asked in seat order
|
|
19
|
+
* and the first yes wins, so a `reject_trade` from one seat only PASSES — the offer stays up
|
|
20
|
+
* for the seats behind it (watch for `trade_declined` rather than `trade_rejected`).
|
|
21
|
+
*/
|
|
22
|
+
export const OPEN_TO_TABLE = -1;
|
|
14
23
|
const asNum = (v, d = 0) => (typeof v === "number" ? v : Number(v ?? d) || d);
|
|
15
24
|
const asStr = (v, d = "") => (typeof v === "string" ? v : d);
|
|
16
25
|
const asArr = (v) => (Array.isArray(v) ? v : []);
|
|
@@ -29,6 +38,7 @@ export function parseView(d) {
|
|
|
29
38
|
scores: asArr(d.scores),
|
|
30
39
|
legal_actions: asArr(d.legal_actions).length ? asArr(d.legal_actions) : asArr(d.your_hand),
|
|
31
40
|
history: asArr(d.history),
|
|
41
|
+
warn_in_ms: Number(d.warn_in_ms ?? 0) || 0,
|
|
32
42
|
raw: d,
|
|
33
43
|
};
|
|
34
44
|
case MONOPOLY:
|
package/dist/movetools.d.ts
CHANGED
|
@@ -21,6 +21,17 @@ export declare function moveTool(game: string, provider?: string, planRounds?: n
|
|
|
21
21
|
* Worth using. Without it a model may answer in prose, and a turn with no tool call is
|
|
22
22
|
* unverified — the agent keeps playing but earns no completion binding.
|
|
23
23
|
*/
|
|
24
|
+
/**
|
|
25
|
+
* The tool_choice value that FORCES the model to answer with the move tool.
|
|
26
|
+
*
|
|
27
|
+
* NOT every model accepts forcing. Some advertise tool support and still reject a
|
|
28
|
+
* required/named tool_choice — observed live: OpenRouter's `openai/gpt-oss-20b:free` answers
|
|
29
|
+
* `inference-enforced tool_choice (required/named) is not supported`, HTTP 400, on every call.
|
|
30
|
+
*
|
|
31
|
+
* If you see that, send `"auto"` instead. Binding reads the RESPONSE, so forcing is only a way
|
|
32
|
+
* to raise the hit rate — a model that emits the tool call on its own binds exactly the same.
|
|
33
|
+
* Forcing is the default because most models take it and it wastes fewer turns.
|
|
34
|
+
*/
|
|
24
35
|
export declare function moveToolChoice(game: string, provider?: string): unknown;
|
|
25
36
|
/**
|
|
26
37
|
* The move arguments the model emitted, or null if it emitted no usable move call.
|