chesspal 0.0.1 → 0.1.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 +136 -2
- package/dist/cli/chat-hook.d.ts +2 -0
- package/dist/cli/chat-hook.js +33 -0
- package/dist/cli/cli.d.ts +2 -0
- package/dist/cli/cli.js +28 -0
- package/dist/cli/command-line.d.ts +8 -0
- package/dist/cli/command-line.js +178 -0
- package/dist/commands/ai-move.d.ts +37 -0
- package/dist/commands/ai-move.js +92 -0
- package/dist/commands/ai-say.d.ts +20 -0
- package/dist/commands/ai-say.js +40 -0
- package/dist/commands/analyse-game.d.ts +61 -0
- package/dist/commands/analyse-game.js +238 -0
- package/dist/commands/annotate-game.d.ts +35 -0
- package/dist/commands/annotate-game.js +78 -0
- package/dist/commands/clock.d.ts +15 -0
- package/dist/commands/clock.js +45 -0
- package/dist/commands/context.d.ts +56 -0
- package/dist/commands/context.js +36 -0
- package/dist/commands/draw-offer.d.ts +10 -0
- package/dist/commands/draw-offer.js +43 -0
- package/dist/commands/drawings.d.ts +15 -0
- package/dist/commands/drawings.js +23 -0
- package/dist/commands/end-game.d.ts +23 -0
- package/dist/commands/end-game.js +64 -0
- package/dist/commands/export-game.d.ts +14 -0
- package/dist/commands/export-game.js +26 -0
- package/dist/commands/game-summary.d.ts +95 -0
- package/dist/commands/game-summary.js +239 -0
- package/dist/commands/index.d.ts +3 -0
- package/dist/commands/index.js +19 -0
- package/dist/commands/init.d.ts +15 -0
- package/dist/commands/init.js +73 -0
- package/dist/commands/levels.d.ts +9 -0
- package/dist/commands/levels.js +25 -0
- package/dist/commands/look.d.ts +31 -0
- package/dist/commands/look.js +61 -0
- package/dist/commands/new-game.d.ts +69 -0
- package/dist/commands/new-game.js +215 -0
- package/dist/commands/opening-book.d.ts +13 -0
- package/dist/commands/opening-book.js +84 -0
- package/dist/commands/page-link.d.ts +11 -0
- package/dist/commands/page-link.js +28 -0
- package/dist/commands/past-games.d.ts +11 -0
- package/dist/commands/past-games.js +47 -0
- package/dist/commands/play-move.d.ts +44 -0
- package/dist/commands/play-move.js +102 -0
- package/dist/commands/player-move.d.ts +17 -0
- package/dist/commands/player-move.js +44 -0
- package/dist/commands/player-say.d.ts +5 -0
- package/dist/commands/player-say.js +16 -0
- package/dist/commands/ponder.d.ts +42 -0
- package/dist/commands/ponder.js +167 -0
- package/dist/commands/position.d.ts +14 -0
- package/dist/commands/position.js +128 -0
- package/dist/commands/profiles.d.ts +60 -0
- package/dist/commands/profiles.js +129 -0
- package/dist/commands/registry.d.ts +54 -0
- package/dist/commands/registry.js +61 -0
- package/dist/commands/say.d.ts +16 -0
- package/dist/commands/say.js +84 -0
- package/dist/commands/status.d.ts +29 -0
- package/dist/commands/status.js +103 -0
- package/dist/commands/study-games.d.ts +5 -0
- package/dist/commands/study-games.js +40 -0
- package/dist/commands/study.d.ts +38 -0
- package/dist/commands/study.js +122 -0
- package/dist/commands/view.d.ts +14 -0
- package/dist/commands/view.js +125 -0
- package/dist/core/board-text.d.ts +7 -0
- package/dist/core/board-text.js +18 -0
- package/dist/core/book.json +1 -0
- package/dist/core/clock.d.ts +45 -0
- package/dist/core/clock.js +109 -0
- package/dist/core/facts.d.ts +55 -0
- package/dist/core/facts.js +175 -0
- package/dist/core/game.d.ts +93 -0
- package/dist/core/game.js +348 -0
- package/dist/core/illegal.d.ts +19 -0
- package/dist/core/illegal.js +141 -0
- package/dist/core/index.d.ts +19 -0
- package/dist/core/index.js +11 -0
- package/dist/core/levels.d.ts +20 -0
- package/dist/core/levels.js +33 -0
- package/dist/core/material.d.ts +9 -0
- package/dist/core/material.js +12 -0
- package/dist/core/moods.d.ts +23 -0
- package/dist/core/moods.js +45 -0
- package/dist/core/openings.d.ts +37 -0
- package/dist/core/openings.js +98 -0
- package/dist/core/openings.json +1 -0
- package/dist/core/pgn.d.ts +10 -0
- package/dist/core/pgn.js +213 -0
- package/dist/core/profile.d.ts +117 -0
- package/dist/core/profile.js +232 -0
- package/dist/core/record.d.ts +21 -0
- package/dist/core/record.js +25 -0
- package/dist/core/review.d.ts +74 -0
- package/dist/core/review.js +126 -0
- package/dist/games/ai-model.d.ts +5 -0
- package/dist/games/ai-model.js +15 -0
- package/dist/games/analysis.d.ts +12 -0
- package/dist/games/analysis.js +26 -0
- package/dist/games/archive.d.ts +42 -0
- package/dist/games/archive.js +168 -0
- package/dist/games/chat-start.d.ts +16 -0
- package/dist/games/chat-start.js +26 -0
- package/dist/games/current-game.d.ts +54 -0
- package/dist/games/current-game.js +128 -0
- package/dist/games/files.d.ts +9 -0
- package/dist/games/files.js +52 -0
- package/dist/games/index.d.ts +10 -0
- package/dist/games/index.js +11 -0
- package/dist/games/plans.d.ts +9 -0
- package/dist/games/plans.js +23 -0
- package/dist/games/table-talk.d.ts +12 -0
- package/dist/games/table-talk.js +25 -0
- package/dist/games/talk.d.ts +40 -0
- package/dist/games/talk.js +131 -0
- package/dist/games/thoughts.d.ts +15 -0
- package/dist/games/thoughts.js +60 -0
- package/dist/home/data-folder.d.ts +12 -0
- package/dist/home/data-folder.js +55 -0
- package/dist/home/index.d.ts +2 -0
- package/dist/home/index.js +3 -0
- package/dist/home/open-folder.d.ts +2 -0
- package/dist/home/open-folder.js +14 -0
- package/dist/host-files/host-files.d.ts +10 -0
- package/dist/host-files/host-files.js +167 -0
- package/dist/host-files/index.d.ts +2 -0
- package/dist/host-files/index.js +3 -0
- package/dist/host-files/ponder-agents.d.ts +12 -0
- package/dist/host-files/ponder-agents.js +40 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/page/app.js +576 -0
- package/dist/page/avatars/beanie.webp +0 -0
- package/dist/page/avatars/beard.webp +0 -0
- package/dist/page/avatars/bun.webp +0 -0
- package/dist/page/avatars/cat.webp +0 -0
- package/dist/page/avatars/curls.webp +0 -0
- package/dist/page/avatars/glasses.webp +0 -0
- package/dist/page/avatars/moustache.webp +0 -0
- package/dist/page/avatars/owl.webp +0 -0
- package/dist/page/avatars/short-hair.webp +0 -0
- package/dist/page/avatars/silver-bob.webp +0 -0
- package/dist/page/avatars.js +16 -0
- package/dist/page/badges.js +50 -0
- package/dist/page/board.js +566 -0
- package/dist/page/browse.js +17 -0
- package/dist/page/clock-time.js +12 -0
- package/dist/page/dialog.js +26 -0
- package/dist/page/ending.js +73 -0
- package/dist/page/explain.js +58 -0
- package/dist/page/fold-tab.js +9 -0
- package/dist/page/folds.js +43 -0
- package/dist/page/game-column.js +44 -0
- package/dist/page/header.js +27 -0
- package/dist/page/helps-view.js +51 -0
- package/dist/page/helps.js +90 -0
- package/dist/page/main.js +8 -0
- package/dist/page/model-name.js +20 -0
- package/dist/page/move-list.js +68 -0
- package/dist/page/new-game-dialog.js +42 -0
- package/dist/page/opening-book.js +14 -0
- package/dist/page/pieces/CREDITS.md +36 -0
- package/dist/page/pieces/cburnett-small.svg +20 -0
- package/dist/page/pieces/cburnett.svg +20 -0
- package/dist/page/pieces/nitida-small.svg +19 -0
- package/dist/page/pieces/nitida.svg +20 -0
- package/dist/page/pieces/torretta-small.svg +19 -0
- package/dist/page/pieces/torretta.svg +19 -0
- package/dist/page/pieces.js +28 -0
- package/dist/page/player-card.js +60 -0
- package/dist/page/plies.js +62 -0
- package/dist/page/profile-dialogs.js +123 -0
- package/dist/page/profile.js +95 -0
- package/dist/page/settings-panel.js +32 -0
- package/dist/page/settings.js +76 -0
- package/dist/page/sounds/capture.ogg +0 -0
- package/dist/page/sounds/check.ogg +0 -0
- package/dist/page/sounds/draw.ogg +0 -0
- package/dist/page/sounds/flag.ogg +0 -0
- package/dist/page/sounds/mate.ogg +0 -0
- package/dist/page/sounds/move.ogg +0 -0
- package/dist/page/sounds/offer.ogg +0 -0
- package/dist/page/sounds/resign.ogg +0 -0
- package/dist/page/sounds.js +105 -0
- package/dist/page/studied.js +14 -0
- package/dist/page/study-dialog.js +58 -0
- package/dist/page/style.css +4398 -0
- package/dist/page/talk.js +172 -0
- package/dist/page/text.js +593 -0
- package/dist/page/torretta.js +38 -0
- package/dist/page/voice.js +38 -0
- package/dist/profiles/avatar.d.ts +23 -0
- package/dist/profiles/avatar.js +84 -0
- package/dist/profiles/front-matter.d.ts +9 -0
- package/dist/profiles/front-matter.js +33 -0
- package/dist/profiles/index.d.ts +3 -0
- package/dist/profiles/index.js +5 -0
- package/dist/profiles/profiles.d.ts +58 -0
- package/dist/profiles/profiles.js +237 -0
- package/dist/profiles/settings.d.ts +12 -0
- package/dist/profiles/settings.js +46 -0
- package/dist/server/analyses.d.ts +17 -0
- package/dist/server/analyses.js +46 -0
- package/dist/server/bell.d.ts +60 -0
- package/dist/server/bell.js +209 -0
- package/dist/server/caller.d.ts +10 -0
- package/dist/server/caller.js +99 -0
- package/dist/server/http.d.ts +16 -0
- package/dist/server/http.js +57 -0
- package/dist/server/mcp.d.ts +14 -0
- package/dist/server/mcp.js +179 -0
- package/dist/server/page.d.ts +46 -0
- package/dist/server/page.js +657 -0
- package/dist/server/profile-routes.d.ts +19 -0
- package/dist/server/profile-routes.js +186 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +3 -0
- package/instructions/analyst.md +8 -0
- package/instructions/opponent.md +15 -0
- package/instructions/ponder.md +9 -0
- package/package.json +66 -5
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
/** What the chat hears from the doorbell: one line for whatever the player did, never what, only where to read it. */
|
|
3
|
+
export declare const bellLine = "The player acted on the board page: call status to see it.";
|
|
4
|
+
/**
|
|
5
|
+
* The doorbell's end on the page's server: the hook of the chat that ended its turn waits here, one at a time, and hears "ring" when the player moves or
|
|
6
|
+
* writes on the page. A ring no hook heard waits for the next one, while it is still news: the chat, awake, may have read it already.
|
|
7
|
+
*/
|
|
8
|
+
export declare class Bell {
|
|
9
|
+
private listener;
|
|
10
|
+
private pending;
|
|
11
|
+
private busy;
|
|
12
|
+
private readonly news;
|
|
13
|
+
private readonly changed;
|
|
14
|
+
/** `news` says whether a ring no hook heard is still worth waking the chat for; `changed` is told when the chat starts or stops listening. */
|
|
15
|
+
constructor(news?: () => boolean, changed?: () => void);
|
|
16
|
+
/** Whether the chat hears the player: in a turn, or waiting for the bell with a hook. */
|
|
17
|
+
get listening(): boolean;
|
|
18
|
+
/** The chat calls a tool, so it is in a turn: true when nobody was listening before. */
|
|
19
|
+
chatCalls(): boolean;
|
|
20
|
+
/** The player acted on the page: the hook waiting hears it now, or the next one as soon as it comes. */
|
|
21
|
+
ring(): void;
|
|
22
|
+
/** A hook waits for the bell, so the chat's turn has ended; the hook before it, of a turn already ended, is let go. */
|
|
23
|
+
listen(request: IncomingMessage, response: ServerResponse): void;
|
|
24
|
+
private tellIf;
|
|
25
|
+
}
|
|
26
|
+
/** Whose page answers at a port, for the hook to find its own: the chat's session, the player's folder, and whether a browser has the page open. */
|
|
27
|
+
export declare function bellProbe(folder: string, open?: boolean): BellProbe;
|
|
28
|
+
/** What a page says of itself at `/api/bell?probe`. */
|
|
29
|
+
export interface BellProbe {
|
|
30
|
+
session: string | null;
|
|
31
|
+
folder: string;
|
|
32
|
+
open: boolean;
|
|
33
|
+
}
|
|
34
|
+
/** The ports a folder's page tries first: one in each of a few ranges, by its path, since Windows may reserve a range. The hook looks for it there. */
|
|
35
|
+
export declare function pagePorts(folder: string): number[];
|
|
36
|
+
/** What Codex's Stop hook answers to wake the chat (T-62): its turn goes on with the doorbell's line. */
|
|
37
|
+
export declare const codexBellAnswer: string;
|
|
38
|
+
/**
|
|
39
|
+
* Reads the hook's input, waits for the bell, and ends the process as the host reads it: for Claude Code's asyncRewake, 2 with the line on stderr; for
|
|
40
|
+
* Codex's Stop hook, 0 with its answer on stdout, since Windows PowerShell, which runs Codex's hooks, turns an exit code 2 into 1. Else 0, saying nothing.
|
|
41
|
+
*/
|
|
42
|
+
export declare function listen(codex?: boolean): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* Waits at the page of the hook's chat until the player moves there: true then; false when there is no page to wait at, or another hook takes its place.
|
|
45
|
+
* The chat's own page is the one with its session; after /clear, which gives the hook a new one, the only page of its folder.
|
|
46
|
+
*/
|
|
47
|
+
export declare function waitForBell(input: string, ports?: number[]): Promise<boolean>;
|
|
48
|
+
/** What the hook heard at the page of its chat, in its folder: the bell, or why not. */
|
|
49
|
+
export type BellHeard = {
|
|
50
|
+
folder: string;
|
|
51
|
+
rang: true;
|
|
52
|
+
} | {
|
|
53
|
+
folder: string;
|
|
54
|
+
rang: false;
|
|
55
|
+
why: string;
|
|
56
|
+
};
|
|
57
|
+
/** Waits at the page of the hook's chat, as waitForBell does, and says what it heard. */
|
|
58
|
+
export declare function bellHeard(input: string, ports?: number[]): Promise<BellHeard>;
|
|
59
|
+
/** Adds a line to the log of the hooks that heard no bell, keeping its last lines; a log that cannot be written never stops the hook. */
|
|
60
|
+
export declare function noteSilence(folder: string, why: string, now?: Date): void;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// The doorbell for the page (D-47): `chesspal listen`, the Stop hook init writes for Claude Code and Codex (T-62), waits at the page of its chat and, when
|
|
2
|
+
// the player moves or writes there (T-65), tells the chat to call status. It never carries the move, the words or the position: status reads them fresh.
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { get } from "node:http";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
7
|
+
import { writeAtomically } from "../games/index.js";
|
|
8
|
+
/** What the chat hears from the doorbell: one line for whatever the player did, never what, only where to read it. */
|
|
9
|
+
export const bellLine = "The player acted on the board page: call status to see it.";
|
|
10
|
+
/**
|
|
11
|
+
* The doorbell's end on the page's server: the hook of the chat that ended its turn waits here, one at a time, and hears "ring" when the player moves or
|
|
12
|
+
* writes on the page. A ring no hook heard waits for the next one, while it is still news: the chat, awake, may have read it already.
|
|
13
|
+
*/
|
|
14
|
+
export class Bell {
|
|
15
|
+
listener;
|
|
16
|
+
pending = false;
|
|
17
|
+
// The chat is in a turn: it called a tool, or a hook woke it, and its turn has not ended yet. A chat never started, or just cleared, is not.
|
|
18
|
+
busy = false;
|
|
19
|
+
news;
|
|
20
|
+
changed;
|
|
21
|
+
/** `news` says whether a ring no hook heard is still worth waking the chat for; `changed` is told when the chat starts or stops listening. */
|
|
22
|
+
constructor(news = () => true, changed = () => undefined) {
|
|
23
|
+
this.news = news;
|
|
24
|
+
this.changed = changed;
|
|
25
|
+
}
|
|
26
|
+
/** Whether the chat hears the player: in a turn, or waiting for the bell with a hook. */
|
|
27
|
+
get listening() {
|
|
28
|
+
return this.busy || this.listener !== undefined;
|
|
29
|
+
}
|
|
30
|
+
/** The chat calls a tool, so it is in a turn: true when nobody was listening before. */
|
|
31
|
+
chatCalls() {
|
|
32
|
+
const asleep = !this.listening;
|
|
33
|
+
this.busy = true;
|
|
34
|
+
this.tellIf(asleep);
|
|
35
|
+
return asleep;
|
|
36
|
+
}
|
|
37
|
+
/** The player acted on the page: the hook waiting hears it now, or the next one as soon as it comes. */
|
|
38
|
+
ring() {
|
|
39
|
+
if (this.listener === undefined) {
|
|
40
|
+
this.pending = true;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
this.listener.end("ring");
|
|
44
|
+
this.listener = undefined;
|
|
45
|
+
this.busy = true;
|
|
46
|
+
}
|
|
47
|
+
/** A hook waits for the bell, so the chat's turn has ended; the hook before it, of a turn already ended, is let go. */
|
|
48
|
+
listen(request, response) {
|
|
49
|
+
response.writeHead(200, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
|
|
50
|
+
const asleep = !this.listening;
|
|
51
|
+
const missed = this.pending;
|
|
52
|
+
this.pending = false;
|
|
53
|
+
if (missed && this.news()) {
|
|
54
|
+
response.end("ring");
|
|
55
|
+
this.busy = true;
|
|
56
|
+
this.tellIf(asleep);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
this.busy = false;
|
|
60
|
+
this.listener?.end("dismissed");
|
|
61
|
+
this.listener = response;
|
|
62
|
+
this.tellIf(asleep);
|
|
63
|
+
request.on("close", () => {
|
|
64
|
+
if (this.listener === response) {
|
|
65
|
+
// The hook went without the bell (the chat stopped, cleared or closed): nobody hears the player until the chat calls a tool again.
|
|
66
|
+
this.listener = undefined;
|
|
67
|
+
this.tellIf(!this.busy);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
tellIf(changed) {
|
|
72
|
+
if (changed) {
|
|
73
|
+
this.changed();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Whose page answers at a port, for the hook to find its own: the chat's session, the player's folder, and whether a browser has the page open. */
|
|
78
|
+
export function bellProbe(folder, open = false) {
|
|
79
|
+
return { session: process.env.CLAUDE_CODE_SESSION_ID ?? null, folder: resolve(folder), open };
|
|
80
|
+
}
|
|
81
|
+
/** The ports a folder's page tries first: one in each of a few ranges, by its path, since Windows may reserve a range. The hook looks for it there. */
|
|
82
|
+
export function pagePorts(folder) {
|
|
83
|
+
const offset = createHash("sha256").update(resolve(folder).toLowerCase()).digest()[0] % 10;
|
|
84
|
+
return [5870, 6870, 7870].map((base) => base + offset);
|
|
85
|
+
}
|
|
86
|
+
/** What Codex's Stop hook answers to wake the chat (T-62): its turn goes on with the doorbell's line. */
|
|
87
|
+
export const codexBellAnswer = JSON.stringify({ decision: "block", reason: bellLine });
|
|
88
|
+
/**
|
|
89
|
+
* Reads the hook's input, waits for the bell, and ends the process as the host reads it: for Claude Code's asyncRewake, 2 with the line on stderr; for
|
|
90
|
+
* Codex's Stop hook, 0 with its answer on stdout, since Windows PowerShell, which runs Codex's hooks, turns an exit code 2 into 1. Else 0, saying nothing.
|
|
91
|
+
*/
|
|
92
|
+
export async function listen(codex = false) {
|
|
93
|
+
let input = "";
|
|
94
|
+
for await (const chunk of process.stdin) {
|
|
95
|
+
input += String(chunk);
|
|
96
|
+
}
|
|
97
|
+
const heard = await bellHeard(input).catch((error) => ({ folder: chatFolder(hookInput(input)), rang: false, why: `failed: ${error.message}` }));
|
|
98
|
+
// Codex's turn ends with a hook that heard no bell, and the player's next move on the page wakes nobody: the log keeps why (T-62).
|
|
99
|
+
if (codex && !heard.rang) {
|
|
100
|
+
noteSilence(heard.folder, heard.why);
|
|
101
|
+
}
|
|
102
|
+
// It ends as soon as it knows: the chat hears the hook only once it ends.
|
|
103
|
+
if (!heard.rang) {
|
|
104
|
+
process.exit(0);
|
|
105
|
+
}
|
|
106
|
+
else if (codex) {
|
|
107
|
+
process.stdout.write(`${codexBellAnswer}\n`, () => process.exit(0));
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
process.stderr.write(`${bellLine}\n`, () => process.exit(2));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Waits at the page of the hook's chat until the player moves there: true then; false when there is no page to wait at, or another hook takes its place.
|
|
115
|
+
* The chat's own page is the one with its session; after /clear, which gives the hook a new one, the only page of its folder.
|
|
116
|
+
*/
|
|
117
|
+
export async function waitForBell(input, ports) {
|
|
118
|
+
return (await bellHeard(input, ports)).rang;
|
|
119
|
+
}
|
|
120
|
+
/** Waits at the page of the hook's chat, as waitForBell does, and says what it heard. */
|
|
121
|
+
export async function bellHeard(input, ports) {
|
|
122
|
+
const hook = hookInput(input);
|
|
123
|
+
const folder = chatFolder(hook);
|
|
124
|
+
const tried = ports ?? pagePorts(folder);
|
|
125
|
+
const probes = await Promise.all(tried.map(async (port) => ({ port, said: probeOf(await body(port, "/api/bell?probe", probeWait)) })));
|
|
126
|
+
const ours = probes.flatMap(({ port, said }) => (said !== undefined && samePath(said.folder, folder) ? [{ port, said }] : []));
|
|
127
|
+
const port = chatPage(ours, hook.session)?.port;
|
|
128
|
+
if (port === undefined) {
|
|
129
|
+
const others = probes.flatMap((one) => (one.said === undefined ? [] : [`${one.port} is ${one.said.folder}'s`]));
|
|
130
|
+
const open = ours.filter(({ said }) => said.open).length;
|
|
131
|
+
const pages = ours.length > 1 ? `${ours.length} pages of it, none of this chat's session, ${open} open in a browser` : "no page of it";
|
|
132
|
+
return { folder, rang: false, why: `${pages} at ports ${tried.join(", ")}${others.length > 0 ? `; ${others.join(", ")}` : ""}` };
|
|
133
|
+
}
|
|
134
|
+
const said = await body(port, "/api/bell");
|
|
135
|
+
return said === "ring" ? { folder, rang: true } : { folder, rang: false, why: said === undefined ? `the page at port ${port} did not answer` : `the page at port ${port} said "${said}"` };
|
|
136
|
+
}
|
|
137
|
+
// How long a port may take to say whose page it is: one of this computer answers at once.
|
|
138
|
+
const probeWait = 1000;
|
|
139
|
+
// The log of Codex's hooks that heard no bell, in the chat's folder: its last lines only.
|
|
140
|
+
const silenceLog = "listen.log";
|
|
141
|
+
const silenceLines = 100;
|
|
142
|
+
/** The chat's folder, where its server started: Claude Code names it, and the hook's cwd follows the cd's of the chat. */
|
|
143
|
+
function chatFolder(hook) {
|
|
144
|
+
return resolve(process.env.CLAUDE_PROJECT_DIR ?? hook.cwd ?? process.cwd());
|
|
145
|
+
}
|
|
146
|
+
/** Adds a line to the log of the hooks that heard no bell, keeping its last lines; a log that cannot be written never stops the hook. */
|
|
147
|
+
export function noteSilence(folder, why, now = new Date()) {
|
|
148
|
+
const path = join(folder, silenceLog);
|
|
149
|
+
try {
|
|
150
|
+
const before = existsSync(path) ? readFileSync(path, "utf8").split("\n").filter((line) => line !== "") : [];
|
|
151
|
+
writeAtomically(path, `${[...before, `${now.toISOString()} ${why}`].slice(-silenceLines).join("\n")}\n`);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// The log only explains; the hook ends the same.
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function hookInput(input) {
|
|
158
|
+
try {
|
|
159
|
+
const parsed = JSON.parse(input);
|
|
160
|
+
return {
|
|
161
|
+
...(typeof parsed.session_id === "string" ? { session: parsed.session_id } : {}),
|
|
162
|
+
...(typeof parsed.cwd === "string" ? { cwd: parsed.cwd } : {}),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return {};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The chat's page among those of its folder: the one of its session; else the only one, or the only one a browser has open, where the player plays. Codex
|
|
171
|
+
* gives the page no session, and a chat closed may leave its page behind for a while.
|
|
172
|
+
*/
|
|
173
|
+
function chatPage(pages, session) {
|
|
174
|
+
const open = pages.filter(({ said }) => said.open);
|
|
175
|
+
return pages.find(({ said }) => session !== undefined && said.session === session) ?? (pages.length === 1 ? pages[0] : open.length === 1 ? open[0] : undefined);
|
|
176
|
+
}
|
|
177
|
+
function probeOf(said) {
|
|
178
|
+
try {
|
|
179
|
+
const parsed = said === undefined ? undefined : JSON.parse(said);
|
|
180
|
+
return parsed === undefined || typeof parsed.folder !== "string"
|
|
181
|
+
? undefined
|
|
182
|
+
: { session: typeof parsed.session === "string" ? parsed.session : null, folder: parsed.folder, open: parsed.open === true };
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function samePath(one, other) {
|
|
189
|
+
return process.platform === "win32" ? resolve(one).toLowerCase() === resolve(other).toLowerCase() : resolve(one) === resolve(other);
|
|
190
|
+
}
|
|
191
|
+
/** The body a page of this computer answers with, once it ends; undefined when it does not answer, or not with 200. */
|
|
192
|
+
function body(port, path, wait) {
|
|
193
|
+
return new Promise((done) => {
|
|
194
|
+
// A connection of its own: Node's shared one ends a request idle for five seconds, and the doorbell waits for hours.
|
|
195
|
+
const request = get({ host: "127.0.0.1", port, path, agent: false, ...(wait === undefined ? {} : { timeout: wait }) }, (response) => {
|
|
196
|
+
let text = "";
|
|
197
|
+
response.setEncoding("utf8");
|
|
198
|
+
response.on("data", (chunk) => {
|
|
199
|
+
text += chunk;
|
|
200
|
+
});
|
|
201
|
+
response.on("end", () => done(response.statusCode === 200 ? text : undefined));
|
|
202
|
+
response.on("error", () => done(undefined));
|
|
203
|
+
});
|
|
204
|
+
if (wait !== undefined) {
|
|
205
|
+
request.on("timeout", () => request.destroy());
|
|
206
|
+
}
|
|
207
|
+
request.on("error", () => done(undefined));
|
|
208
|
+
});
|
|
209
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Caller } from "../commands/context.ts";
|
|
2
|
+
/** The host as it names itself to the server. */
|
|
3
|
+
export interface Client {
|
|
4
|
+
name: string;
|
|
5
|
+
title?: string | undefined;
|
|
6
|
+
}
|
|
7
|
+
/** Who made the call with these metadata: the model and effort the host ties to it, else the host's name; undefined without a host. */
|
|
8
|
+
export declare function callerOf(meta: Record<string, unknown> | undefined, client: Client | undefined, folder: string, wait?: number): Promise<Caller | undefined>;
|
|
9
|
+
/** The model and effort on the transcript's line of a tool call, in the chat's file or its subagents'; Claude Code writes it a few milliseconds late. */
|
|
10
|
+
export declare function lineOfCall(transcript: string, call: string, wait?: number): Promise<Caller | undefined>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Who makes a tool call (D-81): only what the host ties to that call, never what the AI says of itself nor a setting the chat may have changed (T-113).
|
|
2
|
+
// Codex sends the model and the effort with each call; Claude Code writes them into the chat's transcript, on the line of the call.
|
|
3
|
+
import { closeSync, openSync, readdirSync, readSync, statSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { lastChatStart } from "../games/index.js";
|
|
6
|
+
/** Who made the call with these metadata: the model and effort the host ties to it, else the host's name; undefined without a host. */
|
|
7
|
+
export async function callerOf(meta, client, folder, wait = 50) {
|
|
8
|
+
const turn = objectOf(meta?.["x-codex-turn-metadata"]);
|
|
9
|
+
if (typeof turn?.model === "string") {
|
|
10
|
+
return withEffort(turn.model, turn.reasoning_effort);
|
|
11
|
+
}
|
|
12
|
+
const call = meta?.["claudecode/toolUseId"];
|
|
13
|
+
const transcript = lastChatStart(folder)?.transcript;
|
|
14
|
+
if (typeof call === "string" && transcript !== undefined) {
|
|
15
|
+
const found = await lineOfCall(transcript, call, wait);
|
|
16
|
+
if (found !== undefined) {
|
|
17
|
+
return found;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return client === undefined ? undefined : { annotator: client.title ?? client.name, hostOnly: true };
|
|
21
|
+
}
|
|
22
|
+
/** The model and effort on the transcript's line of a tool call, in the chat's file or its subagents'; Claude Code writes it a few milliseconds late. */
|
|
23
|
+
export async function lineOfCall(transcript, call, wait = 50) {
|
|
24
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
25
|
+
for (const file of [transcript, ...subagentFiles(transcript)]) {
|
|
26
|
+
const found = callerIn(tailOf(file), call);
|
|
27
|
+
if (found !== undefined) {
|
|
28
|
+
return found;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (attempt < attempts - 1) {
|
|
32
|
+
await new Promise((done) => setTimeout(done, wait));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
// Three seconds at most: the line comes 30 or 40 milliseconds after a short call, but over 300 after annotate_game's long one. Nobody waits for it on the
|
|
38
|
+
// AI's clock: annotate_game runs with no game going on, and the AI's move is read after its answer.
|
|
39
|
+
const attempts = 60;
|
|
40
|
+
// The line of a call just made is near the end of its file.
|
|
41
|
+
const tailBytes = 512 * 1024;
|
|
42
|
+
// A subagent's file is one written in the last minute.
|
|
43
|
+
const recentFiles = 60_000;
|
|
44
|
+
function callerIn(text, call) {
|
|
45
|
+
const marker = `"id":"${call}"`;
|
|
46
|
+
for (const line of text.split("\n").reverse()) {
|
|
47
|
+
if (!line.includes(marker)) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const entry = objectOf(line);
|
|
51
|
+
const message = objectOf(entry?.message);
|
|
52
|
+
if (entry?.type === "assistant" && typeof message?.model === "string" && message.model !== "<synthetic>") {
|
|
53
|
+
return withEffort(message.model, entry.effort);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
function withEffort(model, effort) {
|
|
59
|
+
return typeof effort === "string" && effort !== "" ? { annotator: model, effort } : { annotator: model };
|
|
60
|
+
}
|
|
61
|
+
function subagentFiles(transcript) {
|
|
62
|
+
const folder = join(dirname(transcript), basename(transcript, ".jsonl"), "subagents");
|
|
63
|
+
try {
|
|
64
|
+
const since = Date.now() - recentFiles;
|
|
65
|
+
return readdirSync(folder).filter((name) => name.endsWith(".jsonl")).map((name) => join(folder, name)).filter((file) => statSync(file).mtimeMs >= since);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function tailOf(file) {
|
|
72
|
+
try {
|
|
73
|
+
const descriptor = openSync(file, "r");
|
|
74
|
+
try {
|
|
75
|
+
const size = statSync(file).size;
|
|
76
|
+
const length = Math.min(size, tailBytes);
|
|
77
|
+
const buffer = Buffer.alloc(length);
|
|
78
|
+
readSync(descriptor, buffer, 0, length, size - length);
|
|
79
|
+
return buffer.toString("utf8");
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
closeSync(descriptor);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return "";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function objectOf(value) {
|
|
90
|
+
if (typeof value === "string") {
|
|
91
|
+
try {
|
|
92
|
+
return objectOf(JSON.parse(value));
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : undefined;
|
|
99
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
export declare const json = "application/json; charset=utf-8";
|
|
3
|
+
/** Answers a request, with nothing cached, sniffed or referred, and the page's policy unless the headers give another. */
|
|
4
|
+
export declare function send(response: ServerResponse, status: number, type: string, body: string | Buffer, headers?: Record<string, string>): void;
|
|
5
|
+
/** Answers with JSON. */
|
|
6
|
+
export declare function sendJson(response: ServerResponse, status: number, body: unknown): void;
|
|
7
|
+
/** Whether a request sends JSON: a form of another site can post text, never JSON without asking first. */
|
|
8
|
+
export declare function isJson(request: IncomingMessage): boolean;
|
|
9
|
+
/** An object from JSON; undefined for anything else. */
|
|
10
|
+
export declare function jsonOf(text: string | undefined): Record<string, unknown> | undefined;
|
|
11
|
+
/** A text field of a body, trimmed; undefined when it is not text, or empty. */
|
|
12
|
+
export declare function textOf(value: unknown): string | undefined;
|
|
13
|
+
/** A request's body as text, up to a size a move with the player's words never reaches; undefined past it. */
|
|
14
|
+
export declare function bodyOf(request: IncomingMessage): Promise<string | undefined>;
|
|
15
|
+
/** A request's body, up to a limit; undefined past it, or when the request breaks. */
|
|
16
|
+
export declare function bytesOf(request: IncomingMessage, limit: number): Promise<Buffer | undefined>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export const json = "application/json; charset=utf-8";
|
|
2
|
+
/** Answers a request, with nothing cached, sniffed or referred, and the page's policy unless the headers give another. */
|
|
3
|
+
export function send(response, status, type, body, headers = {}) {
|
|
4
|
+
response.writeHead(status, {
|
|
5
|
+
"Content-Type": type,
|
|
6
|
+
"Cache-Control": "no-store",
|
|
7
|
+
"X-Content-Type-Options": "nosniff",
|
|
8
|
+
"Referrer-Policy": "no-referrer",
|
|
9
|
+
"Content-Security-Policy": "default-src 'self'",
|
|
10
|
+
...headers,
|
|
11
|
+
});
|
|
12
|
+
response.end(body);
|
|
13
|
+
}
|
|
14
|
+
/** Answers with JSON. */
|
|
15
|
+
export function sendJson(response, status, body) {
|
|
16
|
+
send(response, status, json, JSON.stringify(body));
|
|
17
|
+
}
|
|
18
|
+
/** Whether a request sends JSON: a form of another site can post text, never JSON without asking first. */
|
|
19
|
+
export function isJson(request) {
|
|
20
|
+
return (request.headers["content-type"] ?? "").startsWith("application/json");
|
|
21
|
+
}
|
|
22
|
+
/** An object from JSON; undefined for anything else. */
|
|
23
|
+
export function jsonOf(text) {
|
|
24
|
+
try {
|
|
25
|
+
const parsed = text === undefined ? undefined : JSON.parse(text);
|
|
26
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : undefined;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** A text field of a body, trimmed; undefined when it is not text, or empty. */
|
|
33
|
+
export function textOf(value) {
|
|
34
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
|
|
35
|
+
}
|
|
36
|
+
/** A request's body as text, up to a size a move with the player's words never reaches; undefined past it. */
|
|
37
|
+
export async function bodyOf(request) {
|
|
38
|
+
return (await bytesOf(request, 4096))?.toString("utf8");
|
|
39
|
+
}
|
|
40
|
+
/** A request's body, up to a limit; undefined past it, or when the request breaks. */
|
|
41
|
+
export function bytesOf(request, limit) {
|
|
42
|
+
return new Promise((done) => {
|
|
43
|
+
const chunks = [];
|
|
44
|
+
let size = 0;
|
|
45
|
+
request.on("data", (chunk) => {
|
|
46
|
+
size += chunk.length;
|
|
47
|
+
if (size > limit) {
|
|
48
|
+
request.destroy();
|
|
49
|
+
done(undefined);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
chunks.push(chunk);
|
|
53
|
+
});
|
|
54
|
+
request.on("end", () => done(Buffer.concat(chunks)));
|
|
55
|
+
request.on("error", () => done(undefined));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { type Context } from "../commands/context.ts";
|
|
3
|
+
import { type AnyCommand } from "../commands/registry.ts";
|
|
4
|
+
/** What the host reads as the server starts, before any tool: where to begin, and the rule of every move (D-27). Short, since hosts may cut it. */
|
|
5
|
+
export declare const serverInstructions: string;
|
|
6
|
+
/** The MCP server for an AI host: every command of the registry as a tool, with its description and parameters (D-5). */
|
|
7
|
+
export declare function createMcpServer(context?: Context, list?: AnyCommand[]): McpServer;
|
|
8
|
+
/**
|
|
9
|
+
* Serves the tools over standard input and output, as AI hosts expect: stdout carries the protocol alone, so nothing else may print there. It ends when the
|
|
10
|
+
* host goes and its input closes, once the calls still running have answered: a move half made would be lost.
|
|
11
|
+
*/
|
|
12
|
+
export declare function startMcpServer(context?: Context, list?: AnyCommand[]): Promise<void>;
|
|
13
|
+
/** The opponent's name for the page, from the host's: Claude Code and the desktop app are Claude, Codex is Codex; another host is unnamed. */
|
|
14
|
+
export declare function opponentOf(host: string | undefined): string | undefined;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { aiMove } from "../commands/ai-move.js";
|
|
5
|
+
import { defaultContext } from "../commands/context.js";
|
|
6
|
+
import { commands } from "../commands/index.js";
|
|
7
|
+
import { PositionChanged } from "../commands/play-move.js";
|
|
8
|
+
import { inputProblems, isBoolean } from "../commands/registry.js";
|
|
9
|
+
import { noteAiModel } from "../games/index.js";
|
|
10
|
+
import { gameFolder } from "../profiles/index.js";
|
|
11
|
+
import { version } from "../version.js";
|
|
12
|
+
import { pagePorts } from "./bell.js";
|
|
13
|
+
import { callerOf } from "./caller.js";
|
|
14
|
+
import { chatHasRead, startPage } from "./page.js";
|
|
15
|
+
/** What the host reads as the server starts, before any tool: where to begin, and the rule of every move (D-27). Short, since hosts may cut it. */
|
|
16
|
+
export const serverInstructions = "Chesspal keeps the chess for you: the rules, the board, the clock, the games and the player's memory. Call `status` at the start of every "
|
|
17
|
+
+ "chat. Every move goes through Chesspal's tools: never keep the board in your head or invent a position.";
|
|
18
|
+
/** The MCP server for an AI host: every command of the registry as a tool, with its description and parameters (D-5). */
|
|
19
|
+
export function createMcpServer(context = defaultContext(), list = commands) {
|
|
20
|
+
const server = new McpServer({ name: "chesspal", version }, { instructions: serverInstructions });
|
|
21
|
+
// The server keeps what it told the chat, the instructions a tool gives once, and the position it showed: none yet (D-71).
|
|
22
|
+
context.told ??= new Set();
|
|
23
|
+
context.positionSeen ??= null;
|
|
24
|
+
const forOneHost = [];
|
|
25
|
+
for (const command of list.filter((each) => each.cliOnly !== true)) {
|
|
26
|
+
const tool = server.registerTool(command.name, toolConfig(command), async (input, extra) => {
|
|
27
|
+
// Each call knows who makes it, for the commands that write it down (D-81).
|
|
28
|
+
const caller = () => callerOf(extra._meta, server.server.getClientVersion(), context.folder);
|
|
29
|
+
const result = await track(runTool(command, input, context, caller));
|
|
30
|
+
if (command === aiMove && result.isError !== true) {
|
|
31
|
+
noteTheAi(context, caller);
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
});
|
|
35
|
+
if (command.host !== undefined) {
|
|
36
|
+
tool.disable();
|
|
37
|
+
forOneHost.push({ host: command.host, tool });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// A tool for one host is offered once the host has said its name, before it lists the tools.
|
|
41
|
+
server.server.oninitialized = () => {
|
|
42
|
+
const host = server.server.getClientVersion()?.name;
|
|
43
|
+
for (const { tool } of forOneHost.filter((each) => each.host === host)) {
|
|
44
|
+
tool.enable();
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
return server;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Serves the tools over standard input and output, as AI hosts expect: stdout carries the protocol alone, so nothing else may print there. It ends when the
|
|
51
|
+
* host goes and its input closes, once the calls still running have answered: a move half made would be lost.
|
|
52
|
+
*/
|
|
53
|
+
export async function startMcpServer(context = defaultContext(), list = commands) {
|
|
54
|
+
const leave = async () => {
|
|
55
|
+
// A tool stuck on something that never ends must not keep the server alive once the host has gone.
|
|
56
|
+
await Promise.race([Promise.allSettled(running), new Promise((done) => setTimeout(done, 5_000))]);
|
|
57
|
+
// The SDK sends the answer a moment after the call ends: it must reach stdout before the process goes.
|
|
58
|
+
setImmediate(() => process.stdout.write("", () => process.exit(0)));
|
|
59
|
+
};
|
|
60
|
+
process.stdin.on("end", leave);
|
|
61
|
+
process.stdin.on("close", leave);
|
|
62
|
+
const server = createMcpServer(context, list);
|
|
63
|
+
context.host = () => server.server.getClientVersion()?.name;
|
|
64
|
+
context.page = await pageOf(context, () => opponentOf(context.host?.()));
|
|
65
|
+
await server.connect(new StdioServerTransport());
|
|
66
|
+
}
|
|
67
|
+
/** The opponent's name for the page, from the host's: Claude Code and the desktop app are Claude, Codex is Codex; another host is unnamed. */
|
|
68
|
+
export function opponentOf(host) {
|
|
69
|
+
return /claude/i.test(host ?? "") ? "Claude" : /codex/i.test(host ?? "") ? "Codex" : undefined;
|
|
70
|
+
}
|
|
71
|
+
/** The board page of the player's folder, served by this process for as long as the chat lasts (D-47); without it the game goes on in the chat. */
|
|
72
|
+
async function pageOf(context, opponent) {
|
|
73
|
+
try {
|
|
74
|
+
const { url, heard, chatCalls, open, askNewGame } = await startPage(context, opponent);
|
|
75
|
+
// Words the AI is given change no file: the pages hear of them from the commands.
|
|
76
|
+
context.heard = heard;
|
|
77
|
+
context.calls = chatCalls;
|
|
78
|
+
context.pageOpen = open;
|
|
79
|
+
context.askNewGame = askNewGame;
|
|
80
|
+
if (!pagePorts(context.folder).includes(Number(new URL(url).port))) {
|
|
81
|
+
process.stderr.write(`The board page is at ${url}, off its usual ports: the doorbell cannot find it, so moves on the page do not wake the chat.\n`);
|
|
82
|
+
}
|
|
83
|
+
return url;
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
// The host keeps what the server writes on stderr in its logs.
|
|
87
|
+
process.stderr.write(`${error.message}\n`);
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Who played the AI's move, read once the move has gone back to the chat, and kept in memory (D-82): the AI's card on the page shows it, and the game's
|
|
93
|
+
* PGN once it ends; a change shows from the next move. A host that ties no model to the call leaves the last one known.
|
|
94
|
+
*/
|
|
95
|
+
function noteTheAi(context, caller) {
|
|
96
|
+
// Claude Code writes the call's line a few milliseconds late: read after the answer, it costs the AI's clock nothing (D-13).
|
|
97
|
+
setImmediate(() => {
|
|
98
|
+
// Nothing that fails here may stop the server: the move is played and answered.
|
|
99
|
+
caller().then((found) => {
|
|
100
|
+
if (found === undefined || found.hostOnly === true) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (noteAiModel(gameFolder(context.home()), { model: found.annotator, ...(found.effort === undefined ? {} : { effort: found.effort }) })) {
|
|
104
|
+
context.heard?.();
|
|
105
|
+
}
|
|
106
|
+
}).catch(() => undefined);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
// The tool calls still running, which the server waits for before it leaves.
|
|
110
|
+
const running = new Set();
|
|
111
|
+
function track(call) {
|
|
112
|
+
running.add(call);
|
|
113
|
+
void call.finally(() => running.delete(call));
|
|
114
|
+
return call;
|
|
115
|
+
}
|
|
116
|
+
/** Every hint stated, since the spec reads a missing one as the worst case: everything is local, so no tool reaches the open world. */
|
|
117
|
+
function toolConfig(command) {
|
|
118
|
+
const readOnly = command.readOnly === true;
|
|
119
|
+
const annotations = { readOnlyHint: readOnly, destructiveHint: command.destructive === true, idempotentHint: readOnly || command.idempotent === true, openWorldHint: false };
|
|
120
|
+
const meta = command.alwaysLoad ? { "anthropic/alwaysLoad": true } : undefined;
|
|
121
|
+
// The SDK writes the draft's address in every schema: it says nothing to an AI, and costs tokens in every tool.
|
|
122
|
+
return { description: command.description, inputSchema: z.looseObject({}).meta({ ...shownInput(command), $schema: undefined }), annotations, _meta: meta };
|
|
123
|
+
}
|
|
124
|
+
async function runTool(command, input, context, caller) {
|
|
125
|
+
let refused = false;
|
|
126
|
+
try {
|
|
127
|
+
if (command.byAgent !== true) {
|
|
128
|
+
context.calls?.();
|
|
129
|
+
}
|
|
130
|
+
const result = await command.run(checkedInput(command, input), { ...context, caller });
|
|
131
|
+
return { content: [{ type: "text", text: command.format(result, context) }] };
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
refused = error instanceof PositionChanged;
|
|
135
|
+
return { content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }], isError: true };
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
// What the tool read or wrote, the chat has seen: the doorbell rings it only for what changes after, and it may move on the game a tool showed it,
|
|
139
|
+
// unless that tool was refused for a position it had not seen. An agent's call is not the chat's.
|
|
140
|
+
if (command.byAgent !== true) {
|
|
141
|
+
chatHasRead(context, command.showsGame === true && !refused);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// The JSON schema of each command's input, as the SDK would write it from the strict schema: the tool takes any object, and the command checks it, so that
|
|
146
|
+
// what is wrong comes back in words an AI can act on.
|
|
147
|
+
const shownInputs = new WeakMap();
|
|
148
|
+
function shownInput(command) {
|
|
149
|
+
let shown = shownInputs.get(command);
|
|
150
|
+
if (shown === undefined) {
|
|
151
|
+
shown = z.toJSONSchema(command.input.strict(), { target: "draft-7", io: "input" });
|
|
152
|
+
shownInputs.set(command, shown);
|
|
153
|
+
}
|
|
154
|
+
return shown;
|
|
155
|
+
}
|
|
156
|
+
/** The command's input, checked as its schema says; what is wrong comes with every parameter the tool takes, for a call made without the schema. */
|
|
157
|
+
function checkedInput(command, input) {
|
|
158
|
+
const parsed = command.input.strict().safeParse(withFlags(command, input ?? {}));
|
|
159
|
+
if (parsed.success) {
|
|
160
|
+
return parsed.data;
|
|
161
|
+
}
|
|
162
|
+
const given = typeof input === "object" && input !== null ? input : {};
|
|
163
|
+
const shown = shownInput(command);
|
|
164
|
+
const required = shown.required ?? [];
|
|
165
|
+
const parameters = Object.entries(shown.properties ?? {}).map(([name, field]) => `- ${name}${required.includes(name) ? "" : " (optional)"}${field.description === undefined ? "" : `: ${field.description}`}`);
|
|
166
|
+
const again = parameters.length === 0 ? "call it again with no parameters." : `call it again with its parameters.\n${command.name} takes:\n${parameters.join("\n")}`;
|
|
167
|
+
throw new Error(`${inputProblems(command.name, parsed.error.issues, given, (name) => name)} Nothing changed: ${again}`);
|
|
168
|
+
}
|
|
169
|
+
/** The input with "true" and "false" as flags where the command takes one: some hosts send every value as text. */
|
|
170
|
+
function withFlags(command, input) {
|
|
171
|
+
if (typeof input !== "object" || input === null) {
|
|
172
|
+
return input;
|
|
173
|
+
}
|
|
174
|
+
const { shape } = command.input;
|
|
175
|
+
return Object.fromEntries(Object.entries(input).map(([name, value]) => {
|
|
176
|
+
const field = shape[name];
|
|
177
|
+
return [name, field !== undefined && isBoolean(field) && (value === "true" || value === "false") ? value === "true" : value];
|
|
178
|
+
}));
|
|
179
|
+
}
|