chesspal 0.0.1 → 0.2.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.
Files changed (273) hide show
  1. package/README.md +99 -2
  2. package/dist/cli/chat-hook.d.ts +2 -0
  3. package/dist/cli/chat-hook.js +33 -0
  4. package/dist/cli/cli.d.ts +2 -0
  5. package/dist/cli/cli.js +28 -0
  6. package/dist/cli/command-line.d.ts +7 -0
  7. package/dist/cli/command-line.js +177 -0
  8. package/dist/commands/agents.d.ts +26 -0
  9. package/dist/commands/agents.js +72 -0
  10. package/dist/commands/ai-move.d.ts +28 -0
  11. package/dist/commands/ai-move.js +85 -0
  12. package/dist/commands/ai-say.d.ts +22 -0
  13. package/dist/commands/ai-say.js +58 -0
  14. package/dist/commands/analyse-game.d.ts +65 -0
  15. package/dist/commands/analyse-game.js +265 -0
  16. package/dist/commands/annotate-game.d.ts +39 -0
  17. package/dist/commands/annotate-game.js +131 -0
  18. package/dist/commands/answer-lines.d.ts +28 -0
  19. package/dist/commands/answer-lines.js +144 -0
  20. package/dist/commands/clock.d.ts +15 -0
  21. package/dist/commands/clock.js +45 -0
  22. package/dist/commands/coach.d.ts +45 -0
  23. package/dist/commands/coach.js +344 -0
  24. package/dist/commands/context.d.ts +67 -0
  25. package/dist/commands/context.js +37 -0
  26. package/dist/commands/delete-game.d.ts +7 -0
  27. package/dist/commands/delete-game.js +30 -0
  28. package/dist/commands/delete-review.d.ts +8 -0
  29. package/dist/commands/delete-review.js +34 -0
  30. package/dist/commands/draw-offer.d.ts +10 -0
  31. package/dist/commands/draw-offer.js +43 -0
  32. package/dist/commands/drawings.d.ts +13 -0
  33. package/dist/commands/drawings.js +25 -0
  34. package/dist/commands/end-game.d.ts +23 -0
  35. package/dist/commands/end-game.js +67 -0
  36. package/dist/commands/export-game.d.ts +14 -0
  37. package/dist/commands/export-game.js +27 -0
  38. package/dist/commands/game-summary.d.ts +78 -0
  39. package/dist/commands/game-summary.js +102 -0
  40. package/dist/commands/index.d.ts +18 -0
  41. package/dist/commands/index.js +64 -0
  42. package/dist/commands/init.d.ts +15 -0
  43. package/dist/commands/init.js +73 -0
  44. package/dist/commands/lead-input.d.ts +8 -0
  45. package/dist/commands/lead-input.js +21 -0
  46. package/dist/commands/levels.d.ts +9 -0
  47. package/dist/commands/levels.js +25 -0
  48. package/dist/commands/look.d.ts +31 -0
  49. package/dist/commands/look.js +64 -0
  50. package/dist/commands/new-game.d.ts +68 -0
  51. package/dist/commands/new-game.js +218 -0
  52. package/dist/commands/opening-book.d.ts +13 -0
  53. package/dist/commands/opening-book.js +84 -0
  54. package/dist/commands/page-link.d.ts +11 -0
  55. package/dist/commands/page-link.js +28 -0
  56. package/dist/commands/past-games.d.ts +11 -0
  57. package/dist/commands/past-games.js +47 -0
  58. package/dist/commands/play-move.d.ts +37 -0
  59. package/dist/commands/play-move.js +94 -0
  60. package/dist/commands/player-move.d.ts +13 -0
  61. package/dist/commands/player-move.js +51 -0
  62. package/dist/commands/player-say.d.ts +5 -0
  63. package/dist/commands/player-say.js +16 -0
  64. package/dist/commands/ponder.d.ts +52 -0
  65. package/dist/commands/ponder.js +179 -0
  66. package/dist/commands/position.d.ts +14 -0
  67. package/dist/commands/position.js +130 -0
  68. package/dist/commands/profiles.d.ts +69 -0
  69. package/dist/commands/profiles.js +163 -0
  70. package/dist/commands/registry.d.ts +59 -0
  71. package/dist/commands/registry.js +62 -0
  72. package/dist/commands/say.d.ts +16 -0
  73. package/dist/commands/say.js +84 -0
  74. package/dist/commands/speech.d.ts +8 -0
  75. package/dist/commands/speech.js +20 -0
  76. package/dist/commands/status.d.ts +35 -0
  77. package/dist/commands/status.js +128 -0
  78. package/dist/commands/study-games.d.ts +5 -0
  79. package/dist/commands/study-games.js +40 -0
  80. package/dist/commands/study.d.ts +38 -0
  81. package/dist/commands/study.js +106 -0
  82. package/dist/commands/view.d.ts +14 -0
  83. package/dist/commands/view.js +125 -0
  84. package/dist/core/board-text.d.ts +7 -0
  85. package/dist/core/board-text.js +18 -0
  86. package/dist/core/book.json +1 -0
  87. package/dist/core/clock.d.ts +45 -0
  88. package/dist/core/clock.js +109 -0
  89. package/dist/core/facts.d.ts +55 -0
  90. package/dist/core/facts.js +175 -0
  91. package/dist/core/game.d.ts +95 -0
  92. package/dist/core/game.js +352 -0
  93. package/dist/core/illegal.d.ts +19 -0
  94. package/dist/core/illegal.js +141 -0
  95. package/dist/core/index.d.ts +20 -0
  96. package/dist/core/index.js +12 -0
  97. package/dist/core/lead.d.ts +15 -0
  98. package/dist/core/lead.js +40 -0
  99. package/dist/core/levels.d.ts +20 -0
  100. package/dist/core/levels.js +33 -0
  101. package/dist/core/material.d.ts +9 -0
  102. package/dist/core/material.js +12 -0
  103. package/dist/core/moods.d.ts +21 -0
  104. package/dist/core/moods.js +45 -0
  105. package/dist/core/openings.d.ts +35 -0
  106. package/dist/core/openings.js +98 -0
  107. package/dist/core/openings.json +1 -0
  108. package/dist/core/pgn.d.ts +10 -0
  109. package/dist/core/pgn.js +213 -0
  110. package/dist/core/profile.d.ts +117 -0
  111. package/dist/core/profile.js +232 -0
  112. package/dist/core/record.d.ts +21 -0
  113. package/dist/core/record.js +25 -0
  114. package/dist/core/review.d.ts +80 -0
  115. package/dist/core/review.js +146 -0
  116. package/dist/games/ai-model.d.ts +5 -0
  117. package/dist/games/ai-model.js +15 -0
  118. package/dist/games/analysis.d.ts +12 -0
  119. package/dist/games/analysis.js +26 -0
  120. package/dist/games/archive.d.ts +63 -0
  121. package/dist/games/archive.js +242 -0
  122. package/dist/games/chat-start.d.ts +16 -0
  123. package/dist/games/chat-start.js +26 -0
  124. package/dist/games/coach.d.ts +57 -0
  125. package/dist/games/coach.js +144 -0
  126. package/dist/games/current-game.d.ts +56 -0
  127. package/dist/games/current-game.js +134 -0
  128. package/dist/games/files.d.ts +9 -0
  129. package/dist/games/files.js +53 -0
  130. package/dist/games/index.d.ts +11 -0
  131. package/dist/games/index.js +12 -0
  132. package/dist/games/plans.d.ts +9 -0
  133. package/dist/games/plans.js +23 -0
  134. package/dist/games/table-talk.d.ts +12 -0
  135. package/dist/games/table-talk.js +25 -0
  136. package/dist/games/talk.d.ts +36 -0
  137. package/dist/games/talk.js +131 -0
  138. package/dist/games/thoughts.d.ts +15 -0
  139. package/dist/games/thoughts.js +60 -0
  140. package/dist/home/data-folder.d.ts +12 -0
  141. package/dist/home/data-folder.js +55 -0
  142. package/dist/home/index.d.ts +2 -0
  143. package/dist/home/index.js +3 -0
  144. package/dist/home/open-folder.d.ts +2 -0
  145. package/dist/home/open-folder.js +14 -0
  146. package/dist/host-files/agents.d.ts +20 -0
  147. package/dist/host-files/agents.js +53 -0
  148. package/dist/host-files/host-files.d.ts +15 -0
  149. package/dist/host-files/host-files.js +182 -0
  150. package/dist/host-files/index.d.ts +2 -0
  151. package/dist/host-files/index.js +3 -0
  152. package/dist/index.d.ts +2 -0
  153. package/dist/index.js +3 -0
  154. package/dist/page/app/anchor.js +88 -0
  155. package/dist/page/app/app.js +458 -0
  156. package/dist/page/app/keys.js +94 -0
  157. package/dist/page/app/phase.js +30 -0
  158. package/dist/page/app/served.js +73 -0
  159. package/dist/page/avatars/avatars.js +19 -0
  160. package/dist/page/avatars/beanie.webp +0 -0
  161. package/dist/page/avatars/beard.webp +0 -0
  162. package/dist/page/avatars/bun.webp +0 -0
  163. package/dist/page/avatars/cat.webp +0 -0
  164. package/dist/page/avatars/curls.webp +0 -0
  165. package/dist/page/avatars/glasses.webp +0 -0
  166. package/dist/page/avatars/moustache.webp +0 -0
  167. package/dist/page/avatars/owl.webp +0 -0
  168. package/dist/page/avatars/short-hair.webp +0 -0
  169. package/dist/page/avatars/silver-bob.webp +0 -0
  170. package/dist/page/avatars/thought.js +10 -0
  171. package/dist/page/avatars/torretta.js +38 -0
  172. package/dist/page/base.css +160 -0
  173. package/dist/page/board/board.css +959 -0
  174. package/dist/page/board/board.js +125 -0
  175. package/dist/page/board/explain.js +58 -0
  176. package/dist/page/board/marks.js +160 -0
  177. package/dist/page/board/motion.js +71 -0
  178. package/dist/page/board/pointer.js +239 -0
  179. package/dist/page/board/squares.js +26 -0
  180. package/dist/page/dialogs/analyses.js +195 -0
  181. package/dist/page/dialogs/dialog.js +26 -0
  182. package/dist/page/dialogs/dialogs.css +1228 -0
  183. package/dist/page/dialogs/new-game.js +42 -0
  184. package/dist/page/dialogs/profiles.js +152 -0
  185. package/dist/page/dialogs/search.js +31 -0
  186. package/dist/page/dialogs/settings.js +42 -0
  187. package/dist/page/dialogs/studied.js +39 -0
  188. package/dist/page/dialogs/study.js +51 -0
  189. package/dist/page/game/badges.js +50 -0
  190. package/dist/page/game/browse.js +17 -0
  191. package/dist/page/game/card-fold.js +40 -0
  192. package/dist/page/game/column.js +51 -0
  193. package/dist/page/game/ending.js +73 -0
  194. package/dist/page/game/game.css +577 -0
  195. package/dist/page/game/move-list.js +72 -0
  196. package/dist/page/game/opening-book.js +14 -0
  197. package/dist/page/game/plies.js +82 -0
  198. package/dist/page/game/result-tile.js +14 -0
  199. package/dist/page/helps/helps.css +689 -0
  200. package/dist/page/helps/helps.js +220 -0
  201. package/dist/page/helps/view.js +127 -0
  202. package/dist/page/layout/fold-tab.js +11 -0
  203. package/dist/page/layout/folds.js +43 -0
  204. package/dist/page/layout/header.js +28 -0
  205. package/dist/page/layout/layout.css +834 -0
  206. package/dist/page/lead/lead.js +82 -0
  207. package/dist/page/lead/view.js +38 -0
  208. package/dist/page/main.js +8 -0
  209. package/dist/page/pieces/CREDITS.md +19 -0
  210. package/dist/page/pieces/capablanca-small.svg +20 -0
  211. package/dist/page/pieces/capablanca.svg +22 -0
  212. package/dist/page/pieces/nitida-small.svg +20 -0
  213. package/dist/page/pieces/nitida.svg +21 -0
  214. package/dist/page/pieces/pieces.js +28 -0
  215. package/dist/page/pieces/torretta-small.svg +20 -0
  216. package/dist/page/pieces/torretta.svg +21 -0
  217. package/dist/page/players/card.js +56 -0
  218. package/dist/page/players/clock-time.js +12 -0
  219. package/dist/page/players/model-name.js +20 -0
  220. package/dist/page/profile/profile.js +99 -0
  221. package/dist/page/profile/settings.js +77 -0
  222. package/dist/page/sounds/capture.ogg +0 -0
  223. package/dist/page/sounds/check.ogg +0 -0
  224. package/dist/page/sounds/draw.ogg +0 -0
  225. package/dist/page/sounds/flag.ogg +0 -0
  226. package/dist/page/sounds/mate.ogg +0 -0
  227. package/dist/page/sounds/move.ogg +0 -0
  228. package/dist/page/sounds/offer.ogg +0 -0
  229. package/dist/page/sounds/resign.ogg +0 -0
  230. package/dist/page/sounds/sounds.js +105 -0
  231. package/dist/page/style.css +9 -0
  232. package/dist/page/talk/talk.css +958 -0
  233. package/dist/page/talk/talk.js +165 -0
  234. package/dist/page/talk/voice.js +38 -0
  235. package/dist/page/text.js +711 -0
  236. package/dist/page/theme.css +292 -0
  237. package/dist/profiles/avatar.d.ts +23 -0
  238. package/dist/profiles/avatar.js +84 -0
  239. package/dist/profiles/front-matter.d.ts +9 -0
  240. package/dist/profiles/front-matter.js +33 -0
  241. package/dist/profiles/index.d.ts +3 -0
  242. package/dist/profiles/index.js +5 -0
  243. package/dist/profiles/profiles.d.ts +69 -0
  244. package/dist/profiles/profiles.js +254 -0
  245. package/dist/profiles/settings.d.ts +12 -0
  246. package/dist/profiles/settings.js +46 -0
  247. package/dist/server/bell.d.ts +68 -0
  248. package/dist/server/bell.js +225 -0
  249. package/dist/server/caller.d.ts +21 -0
  250. package/dist/server/caller.js +132 -0
  251. package/dist/server/mcp.d.ts +13 -0
  252. package/dist/server/mcp.js +207 -0
  253. package/dist/server/page/analyses.d.ts +23 -0
  254. package/dist/server/page/analyses.js +65 -0
  255. package/dist/server/page/files.d.ts +5 -0
  256. package/dist/server/page/files.js +92 -0
  257. package/dist/server/page/game.d.ts +6 -0
  258. package/dist/server/page/game.js +381 -0
  259. package/dist/server/page/http.d.ts +29 -0
  260. package/dist/server/page/http.js +68 -0
  261. package/dist/server/page/page.d.ts +41 -0
  262. package/dist/server/page/page.js +229 -0
  263. package/dist/server/page/profiles.d.ts +19 -0
  264. package/dist/server/page/profiles.js +215 -0
  265. package/dist/version.d.ts +2 -0
  266. package/dist/version.js +3 -0
  267. package/instructions/analysis.md +4 -0
  268. package/instructions/analyst-agent.md +8 -0
  269. package/instructions/analyst.md +4 -0
  270. package/instructions/coach.md +11 -0
  271. package/instructions/opponent.md +15 -0
  272. package/instructions/ponder.md +9 -0
  273. package/package.json +66 -5
@@ -0,0 +1,132 @@
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.
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, readFileSync, 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
+ /**
23
+ * Who in Claude Code made a tool call: the chat, when its line is in the chat's transcript, or the agent whose file has it, by the type Claude Code notes
24
+ * beside that file (Priya). Undefined when it cannot be told: another host, no chat noted, no line, or no type.
25
+ */
26
+ export async function callingAgent(meta, folder, wait = 50) {
27
+ const call = meta?.["claudecode/toolUseId"];
28
+ const transcript = lastChatStart(folder)?.transcript;
29
+ const found = typeof call === "string" && transcript !== undefined ? await foundCall(transcript, call, wait) : undefined;
30
+ if (found === undefined) {
31
+ return undefined;
32
+ }
33
+ if (found.file === transcript) {
34
+ return { chat: true };
35
+ }
36
+ const type = objectOf(textOf(found.file.replace(/\.jsonl$/, ".meta.json")))?.agentType;
37
+ return typeof type === "string" ? { agent: type } : undefined;
38
+ }
39
+ /** Whether an agent's tool may answer this caller: one of its agents, or a caller that cannot be told, since the tool's description is the first guard. */
40
+ export function allowedCaller(caller, agents) {
41
+ return caller === undefined || ("agent" in caller && agents.includes(caller.agent));
42
+ }
43
+ /** 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. */
44
+ async function lineOfCall(transcript, call, wait = 50) {
45
+ return (await foundCall(transcript, call, wait))?.caller;
46
+ }
47
+ // 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
48
+ // AI's clock: annotate_game runs with no game going on, and the AI's move is read after its answer.
49
+ const attempts = 60;
50
+ // The line of a call just made is near the end of its file.
51
+ const tailBytes = 512 * 1024;
52
+ // A subagent's file is one written in the last minute.
53
+ const recentFiles = 60_000;
54
+ /** Who made a tool call, and the file its line is in, the chat's own or a subagent's; none when no line has it. */
55
+ async function foundCall(transcript, call, wait) {
56
+ for (let attempt = 0; attempt < attempts; attempt++) {
57
+ for (const file of [transcript, ...subagentFiles(transcript)]) {
58
+ const found = callerIn(tailOf(file), call);
59
+ if (found !== undefined) {
60
+ return { caller: found, file };
61
+ }
62
+ }
63
+ if (attempt < attempts - 1) {
64
+ await new Promise((done) => setTimeout(done, wait));
65
+ }
66
+ }
67
+ return undefined;
68
+ }
69
+ function callerIn(text, call) {
70
+ const marker = `"id":"${call}"`;
71
+ for (const line of text.split("\n").reverse()) {
72
+ if (!line.includes(marker)) {
73
+ continue;
74
+ }
75
+ const entry = objectOf(line);
76
+ const message = objectOf(entry?.message);
77
+ if (entry?.type === "assistant" && typeof message?.model === "string" && message.model !== "<synthetic>") {
78
+ return withEffort(message.model, entry.effort);
79
+ }
80
+ }
81
+ return undefined;
82
+ }
83
+ function withEffort(model, effort) {
84
+ return typeof effort === "string" && effort !== "" ? { annotator: model, effort } : { annotator: model };
85
+ }
86
+ function subagentFiles(transcript) {
87
+ const folder = join(dirname(transcript), basename(transcript, ".jsonl"), "subagents");
88
+ try {
89
+ const since = Date.now() - recentFiles;
90
+ return readdirSync(folder).filter((name) => name.endsWith(".jsonl")).map((name) => join(folder, name)).filter((file) => statSync(file).mtimeMs >= since);
91
+ }
92
+ catch {
93
+ return [];
94
+ }
95
+ }
96
+ function tailOf(file) {
97
+ try {
98
+ const descriptor = openSync(file, "r");
99
+ try {
100
+ const size = statSync(file).size;
101
+ const length = Math.min(size, tailBytes);
102
+ const buffer = Buffer.alloc(length);
103
+ readSync(descriptor, buffer, 0, length, size - length);
104
+ return buffer.toString("utf8");
105
+ }
106
+ finally {
107
+ closeSync(descriptor);
108
+ }
109
+ }
110
+ catch {
111
+ return "";
112
+ }
113
+ }
114
+ function textOf(file) {
115
+ try {
116
+ return readFileSync(file, "utf8");
117
+ }
118
+ catch {
119
+ return "";
120
+ }
121
+ }
122
+ function objectOf(value) {
123
+ if (typeof value === "string") {
124
+ try {
125
+ return objectOf(JSON.parse(value));
126
+ }
127
+ catch {
128
+ return undefined;
129
+ }
130
+ }
131
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : undefined;
132
+ }
@@ -0,0 +1,13 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { type AnyCommand, type Context } from "../commands/index.ts";
3
+ /** 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. */
4
+ export declare const serverInstructions: string;
5
+ /** The MCP server for an AI host: every command of the registry as a tool, with its description and parameters (D-5). */
6
+ export declare function createMcpServer(context?: Context, list?: AnyCommand[]): McpServer;
7
+ /**
8
+ * 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
9
+ * host goes and its input closes, once the calls still running have answered: a move half made would be lost.
10
+ */
11
+ export declare function startMcpServer(context?: Context, list?: AnyCommand[]): Promise<void>;
12
+ /** 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. */
13
+ export declare function opponentOf(host: string | undefined): string | undefined;
@@ -0,0 +1,207 @@
1
+ // The MCP server an AI host starts: every command as a tool (D-5), and the board page served for as long as the chat lasts (D-47).
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import { aiMove, commands, defaultContext, inputProblems, isBoolean, PositionChanged } from "../commands/index.js";
6
+ import { endOf, loadGame, noteAiModel } from "../games/index.js";
7
+ import { hasAgent } from "../host-files/index.js";
8
+ import { gameFolder } from "../profiles/index.js";
9
+ import { version } from "../version.js";
10
+ import { pagePorts } from "./bell.js";
11
+ import { allowedCaller, callerOf, callingAgent } from "./caller.js";
12
+ import { chatHasRead, startPage } from "./page/page.js";
13
+ /** 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. */
14
+ 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 "
15
+ + "chat. Every move goes through Chesspal's tools: never keep the board in your head or invent a position.";
16
+ /** The MCP server for an AI host: every command of the registry as a tool, with its description and parameters (D-5). */
17
+ export function createMcpServer(context = defaultContext(), list = commands) {
18
+ const server = new McpServer({ name: "chesspal", version }, { instructions: serverInstructions });
19
+ // The server keeps what it told the chat, the instructions a tool gives once, and the position it showed: none yet (D-71).
20
+ context.told ??= new Set();
21
+ context.positionSeen ??= null;
22
+ const forOneHost = [];
23
+ for (const command of list.filter((each) => each.cliOnly !== true)) {
24
+ const tool = server.registerTool(command.name, toolConfig(command), async (input, extra) => {
25
+ // An agent's tool called by the chat, or by another agent, would give what only that agent may see (Priya).
26
+ if (command.byAgents !== undefined && !allowedCaller(await callingAgent(extra._meta, context.folder), command.byAgents)) {
27
+ // It names no agent to start: the chat that asked would start one itself (Priya).
28
+ return { content: [{ type: "text", text: `${command.name} is not yours to call: the player reads what it gives on the board page. Nothing changed.` }], isError: true };
29
+ }
30
+ // Each call knows who makes it, for the commands that write it down (D-81).
31
+ const caller = () => callerOf(extra._meta, server.server.getClientVersion(), context.folder);
32
+ const result = await track(runTool(command, input, context, caller, extra._meta));
33
+ if (command === aiMove && result.isError !== true) {
34
+ noteTheAi(context, caller);
35
+ }
36
+ return result;
37
+ });
38
+ if (command.host !== undefined) {
39
+ tool.disable();
40
+ forOneHost.push({ host: command.host, tool });
41
+ }
42
+ }
43
+ // A tool for one host is offered once the host has said its name, before it lists the tools.
44
+ server.server.oninitialized = () => {
45
+ const host = server.server.getClientVersion()?.name;
46
+ for (const { tool } of forOneHost.filter((each) => each.host === host)) {
47
+ tool.enable();
48
+ }
49
+ };
50
+ return server;
51
+ }
52
+ /**
53
+ * 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
54
+ * host goes and its input closes, once the calls still running have answered: a move half made would be lost.
55
+ */
56
+ export async function startMcpServer(context = defaultContext(), list = commands) {
57
+ const leave = async () => {
58
+ // A tool stuck on something that never ends must not keep the server alive once the host has gone.
59
+ await Promise.race([Promise.allSettled(running), new Promise((done) => setTimeout(done, 5_000))]);
60
+ // The SDK sends the answer a moment after the call ends: it must reach stdout before the process goes.
61
+ setImmediate(() => process.stdout.write("", () => process.exit(0)));
62
+ };
63
+ process.stdin.on("end", leave);
64
+ process.stdin.on("close", leave);
65
+ const server = createMcpServer(context, list);
66
+ context.host = () => server.server.getClientVersion()?.name;
67
+ context.page = await pageOf(context, () => opponentOf(context.host?.()));
68
+ await server.connect(new StdioServerTransport());
69
+ }
70
+ /** 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. */
71
+ export function opponentOf(host) {
72
+ return /claude/i.test(host ?? "") ? "Claude" : /codex/i.test(host ?? "") ? "Codex" : undefined;
73
+ }
74
+ /** 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. */
75
+ async function pageOf(context, opponent) {
76
+ try {
77
+ const { url, heard, chatCalls, ponders, open, askNewGame } = await startPage(context, opponent);
78
+ // Words the AI is given change no file: the pages hear of them from the commands.
79
+ context.heard = heard;
80
+ context.calls = chatCalls;
81
+ context.ponders = ponders;
82
+ context.pageOpen = open;
83
+ context.askNewGame = askNewGame;
84
+ if (!pagePorts(context.folder).includes(Number(new URL(url).port))) {
85
+ 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`);
86
+ }
87
+ return url;
88
+ }
89
+ catch (error) {
90
+ // The host keeps what the server writes on stderr in its logs.
91
+ process.stderr.write(`${error.message}\n`);
92
+ return undefined;
93
+ }
94
+ }
95
+ /**
96
+ * 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
97
+ * 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.
98
+ */
99
+ function noteTheAi(context, caller) {
100
+ // Claude Code writes the call's line a few milliseconds late: read after the answer, it costs the AI's clock nothing (D-13).
101
+ setImmediate(() => {
102
+ // Nothing that fails here may stop the server: the move is played and answered.
103
+ caller().then((found) => {
104
+ if (found === undefined || found.hostOnly === true) {
105
+ return;
106
+ }
107
+ if (noteAiModel(gameFolder(context.home()), { model: found.annotator, ...(found.effort === undefined ? {} : { effort: found.effort }) })) {
108
+ context.heard?.();
109
+ }
110
+ }).catch(() => undefined);
111
+ });
112
+ }
113
+ // The tool calls still running, which the server waits for before it leaves.
114
+ const running = new Set();
115
+ function track(call) {
116
+ running.add(call);
117
+ void call.finally(() => running.delete(call));
118
+ return call;
119
+ }
120
+ /** Every hint stated, since the spec reads a missing one as the worst case: everything is local, so no tool reaches the open world. */
121
+ function toolConfig(command) {
122
+ const readOnly = command.readOnly === true;
123
+ const annotations = { readOnlyHint: readOnly, destructiveHint: command.destructive === true, idempotentHint: readOnly || command.idempotent === true, openWorldHint: false };
124
+ const meta = command.alwaysLoad ? { "anthropic/alwaysLoad": true } : undefined;
125
+ // The SDK writes the draft's address in every schema: it says nothing to an AI, and costs tokens in every tool.
126
+ return { description: command.description, inputSchema: z.looseObject({}).meta({ ...shownInput(command), $schema: undefined }), annotations, _meta: meta };
127
+ }
128
+ async function runTool(command, input, context, caller, meta) {
129
+ const agent = await sharingAgent(command, meta, context);
130
+ const chat = command.byAgents === undefined && agent === undefined;
131
+ let refused = false;
132
+ try {
133
+ if (chat) {
134
+ context.calls?.();
135
+ }
136
+ const result = await command.run(checkedInput(command, input), { ...context, caller, ...(agent === undefined ? {} : { agent }) });
137
+ return { content: [{ type: "text", text: command.format(result, context) }] };
138
+ }
139
+ catch (error) {
140
+ refused = error instanceof PositionChanged;
141
+ return { content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }], isError: true };
142
+ }
143
+ finally {
144
+ // 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,
145
+ // unless that tool was refused for a position it had not seen. An agent's call is not the chat's.
146
+ if (chat) {
147
+ chatHasRead(context, command.showsGame === true && !refused);
148
+ }
149
+ }
150
+ }
151
+ /**
152
+ * The agent that calls a tool it shares with the chat, as the analyst (D-89), by its type: looked up only in a folder where one may run and while no game
153
+ * goes on, so that no call of the chat's waits for it while it plays; none for the chat, or when it cannot be told.
154
+ */
155
+ async function sharingAgent(command, meta, context) {
156
+ const agents = command.sharedWith?.filter((agent) => hasAgent(context.folder, agent)) ?? [];
157
+ if (agents.length === 0 || gameGoingOn(context)) {
158
+ return undefined;
159
+ }
160
+ const caller = await callingAgent(meta, context.folder);
161
+ return caller !== undefined && "agent" in caller && agents.includes(caller.agent) ? caller.agent : undefined;
162
+ }
163
+ /** Whether a game goes on, or may: a game that cannot be read is the command's to report, as the chat's call (Federico). */
164
+ function gameGoingOn(context) {
165
+ try {
166
+ const current = loadGame(gameFolder(context.home()));
167
+ return current !== undefined && endOf(current.game) === undefined;
168
+ }
169
+ catch {
170
+ return true;
171
+ }
172
+ }
173
+ // 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
174
+ // what is wrong comes back in words an AI can act on.
175
+ const shownInputs = new WeakMap();
176
+ function shownInput(command) {
177
+ let shown = shownInputs.get(command);
178
+ if (shown === undefined) {
179
+ shown = z.toJSONSchema(command.input.strict(), { target: "draft-7", io: "input" });
180
+ shownInputs.set(command, shown);
181
+ }
182
+ return shown;
183
+ }
184
+ /** 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. */
185
+ function checkedInput(command, input) {
186
+ const parsed = command.input.strict().safeParse(withFlags(command, input ?? {}));
187
+ if (parsed.success) {
188
+ return parsed.data;
189
+ }
190
+ const given = typeof input === "object" && input !== null ? input : {};
191
+ const shown = shownInput(command);
192
+ const required = shown.required ?? [];
193
+ const parameters = Object.entries(shown.properties ?? {}).map(([name, field]) => `- ${name}${required.includes(name) ? "" : " (optional)"}${field.description === undefined ? "" : `: ${field.description}`}`);
194
+ 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")}`;
195
+ throw new Error(`${inputProblems(command.name, parsed.error.issues, given, (name) => name)} Nothing changed: ${again}`);
196
+ }
197
+ /** The input with "true" and "false" as flags where the command takes one: some hosts send every value as text. */
198
+ function withFlags(command, input) {
199
+ if (typeof input !== "object" || input === null) {
200
+ return input;
201
+ }
202
+ const { shape } = command.input;
203
+ return Object.fromEntries(Object.entries(input).map(([name, value]) => {
204
+ const field = shape[name];
205
+ return [name, field !== undefined && isBoolean(field) && (value === "true" || value === "false") ? value === "true" : value];
206
+ }));
207
+ }
@@ -0,0 +1,23 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { type AiModel } from "../../core/index.ts";
3
+ import type { Served } from "./page.ts";
4
+ /** An analysis as the page lists it: who played, with the AI's model (D-82), when, how it ended, its opening, how long, and whether the AI has reviewed it. */
5
+ export interface AnalysisItem {
6
+ readonly name: string;
7
+ readonly white: string;
8
+ readonly black: string;
9
+ readonly aiModel?: AiModel;
10
+ readonly date?: string;
11
+ readonly result: string;
12
+ readonly opening?: string;
13
+ readonly moves: number;
14
+ readonly reviewed: boolean;
15
+ /** Who reviewed it (D-81). */
16
+ readonly annotator?: AiModel;
17
+ /** Where a game loaded from elsewhere was played, as its Site tag says: the player's name there makes it theirs (D-93). */
18
+ readonly site?: string;
19
+ }
20
+ /** The games to analyse in the profile in use, the latest first, all of them for the page's search: those kept to study and those played, reviewed or not. */
21
+ export declare function analysesOf(home: string): AnalysisItem[];
22
+ /** Answers the page's deletions, of a game or of its review alone, done as the command line does them: true when the request was one. */
23
+ export declare function answerAnalyses(request: IncomingMessage, response: ServerResponse, url: URL, served: Served): boolean;
@@ -0,0 +1,65 @@
1
+ import { bookOpening, deleteGame, deleteReview } from "../../commands/index.js";
2
+ import { aiModelIn, annotatorOf, isReviewed } from "../../core/index.js";
3
+ import { archivedGame, archivedGames, gameStemOf, keptGame, keptGames } from "../../games/index.js";
4
+ import { gameFolder } from "../../profiles/index.js";
5
+ import { bodyOf, isJson, jsonOf, runRoute, sendJson, textOf } from "./http.js";
6
+ /** The games to analyse in the profile in use, the latest first, all of them for the page's search: those kept to study and those played, reviewed or not. */
7
+ export function analysesOf(home) {
8
+ const folder = gameFolder(home);
9
+ // By the time in their names, which orders kept and played games together.
10
+ const stamp = (name) => gameStemOf(name) ?? "";
11
+ const names = [...keptGames(folder), ...archivedGames(folder)].sort((one, other) => stamp(other).localeCompare(stamp(one)));
12
+ return names.flatMap((name) => itemOf(name, () => name.startsWith("study/") ? keptGame(folder, name) : archivedGame(folder, name)));
13
+ }
14
+ /** Answers the page's deletions, of a game or of its review alone, done as the command line does them: true when the request was one. */
15
+ export function answerAnalyses(request, response, url, served) {
16
+ const command = request.method === "POST" ? deletions[url.pathname] : undefined;
17
+ if (command === undefined) {
18
+ return false;
19
+ }
20
+ runRoute(response, async () => {
21
+ const game = textOf(jsonOf(isJson(request) ? await bodyOf(request) : undefined)?.game);
22
+ if (game === undefined) {
23
+ sendJson(response, 400, { error: 'Send the game as JSON: {"game": "games/2026-11-02-153012.pgn"}.' });
24
+ return;
25
+ }
26
+ try {
27
+ sendJson(response, 200, await command.run({ game }, served.context));
28
+ }
29
+ catch (error) {
30
+ sendJson(response, 409, { error: error.message });
31
+ }
32
+ });
33
+ return true;
34
+ }
35
+ const deletions = { "/api/delete-game": deleteGame, "/api/delete-review": deleteReview };
36
+ function itemOf(name, read) {
37
+ let record;
38
+ try {
39
+ record = read();
40
+ }
41
+ catch {
42
+ // A game that cannot be read stays out of the list.
43
+ return [];
44
+ }
45
+ const { tags } = record;
46
+ const date = tags.Date === undefined || tags.Date.includes("?") ? undefined : tags.Date;
47
+ const opening = bookOpening(record)?.name;
48
+ const aiModel = aiModelIn(record);
49
+ const site = tags.Site === undefined || /^[?\-\s]*$/.test(tags.Site) ? undefined : tags.Site;
50
+ const signed = annotatorOf(record);
51
+ const annotator = signed === undefined ? undefined : { model: signed.annotator, ...(signed.effort === undefined ? {} : { effort: signed.effort }) };
52
+ return [{
53
+ name,
54
+ white: tags.White ?? "?",
55
+ black: tags.Black ?? "?",
56
+ ...(aiModel === undefined ? {} : { aiModel }),
57
+ ...(date === undefined ? {} : { date }),
58
+ result: tags.Result ?? "*",
59
+ ...(opening === undefined ? {} : { opening }),
60
+ moves: Math.ceil(record.moves.length / 2),
61
+ reviewed: isReviewed(record),
62
+ ...(annotator === undefined ? {} : { annotator }),
63
+ ...(site === undefined ? {} : { site }),
64
+ }];
65
+ }
@@ -0,0 +1,5 @@
1
+ import type { ServerResponse } from "node:http";
2
+ /** The page's HTML, which runs only its own files and its import map. */
3
+ export declare function sendHtml(response: ServerResponse): void;
4
+ /** A script, style, picture or sound of the page, a module of the core, or a package's module: only from their folders, and only of the types the page uses. */
5
+ export declare function sendFile(response: ServerResponse, path: string): void;
@@ -0,0 +1,92 @@
1
+ // The page's files: its HTML with the import map, its scripts, styles, pictures and sounds, the chess core and the packages they import, each only from its
2
+ // folder and of the types the page uses.
3
+ import { createHash } from "node:crypto";
4
+ import { readFileSync } from "node:fs";
5
+ import { createRequire } from "node:module";
6
+ import { extname, isAbsolute, join, relative, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { send } from "./http.js";
9
+ /** The page's HTML, which runs only its own files and its import map. */
10
+ export function sendHtml(response) {
11
+ send(response, 200, "text/html; charset=utf-8", html, { "Content-Security-Policy": policy });
12
+ }
13
+ /** A script, style, picture or sound of the page, a module of the core, or a package's module: only from their folders, and only of the types the page uses. */
14
+ export function sendFile(response, path) {
15
+ const file = path.startsWith("/vendor/") ? vendorModule(path.slice("/vendor/".length)) : builtFile(path);
16
+ const type = file === undefined ? undefined : types[extname(file)];
17
+ let bytes;
18
+ try {
19
+ bytes = file === undefined || type === undefined ? undefined : readFileSync(file);
20
+ }
21
+ catch {
22
+ bytes = undefined;
23
+ }
24
+ if (bytes === undefined || type === undefined) {
25
+ send(response, 404, "text/plain; charset=utf-8", `Nothing at ${path}.`);
26
+ return;
27
+ }
28
+ // A pieces' set loaded again for each piece that moves blanks the pieces drawn from it, at times until the next move: the browser keeps it an hour,
29
+ // and the drawn avatars too, all at once in the profile's dialog.
30
+ const kept = type === types[".svg"] || type === types[".webp"];
31
+ send(response, 200, type, bytes, kept ? { "Cache-Control": "max-age=3600" } : {});
32
+ }
33
+ // The built page and core: dist/, from src/server/page and from dist/server/page alike.
34
+ const built = fileURLToPath(new URL("../../../dist/", import.meta.url));
35
+ // The modules the page imports from packages, by the names the import map gives them: a package, and its file.
36
+ const vendorModules = {
37
+ "preact.js": ["preact", "dist/preact.module.js"],
38
+ "hooks.js": ["preact", "hooks/dist/hooks.module.js"],
39
+ "jsx-runtime.js": ["preact", "jsx-runtime/dist/jsxRuntime.module.js"],
40
+ "chess.js": ["chess.js", "dist/esm/chess.js"],
41
+ };
42
+ const importMap = JSON.stringify({
43
+ imports: { "preact": "/vendor/preact.js", "preact/hooks": "/vendor/hooks.js", "preact/jsx-runtime": "/vendor/jsx-runtime.js", "chess.js": "/vendor/chess.js" },
44
+ });
45
+ // The page runs only its own files and the import map written in it, known by its hash.
46
+ const policy = `default-src 'self'; script-src 'self' 'sha256-${createHash("sha256").update(importMap).digest("base64")}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; `
47
+ + "base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
48
+ const html = [
49
+ "<!doctype html>",
50
+ '<html lang="en">',
51
+ '<meta charset="utf-8">',
52
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
53
+ '<meta name="color-scheme" content="light dark">',
54
+ "<title>Chesspal</title>",
55
+ '<link rel="stylesheet" href="/page/style.css">',
56
+ `<script type="importmap">${importMap}</script>`,
57
+ '<script type="module" src="/page/main.js"></script>',
58
+ '<div id="app"></div>',
59
+ "",
60
+ ].join("\n");
61
+ const types = { ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".webp": "image/webp", ".json": "application/json; charset=utf-8", ".ogg": "audio/ogg" };
62
+ function builtFile(path) {
63
+ const area = /^\/(page|core)\//.exec(path)?.[1];
64
+ if (area === undefined) {
65
+ return undefined;
66
+ }
67
+ try {
68
+ const root = join(built, area);
69
+ const file = resolve(built, `.${decodeURIComponent(path)}`);
70
+ const inside = relative(root, file);
71
+ return inside === "" || inside.startsWith("..") || isAbsolute(inside) ? undefined : file;
72
+ }
73
+ catch {
74
+ // An address that does not decode.
75
+ return undefined;
76
+ }
77
+ }
78
+ function vendorModule(name) {
79
+ const module = vendorModules[name];
80
+ if (module === undefined) {
81
+ return undefined;
82
+ }
83
+ try {
84
+ // The package's folder, from where its main file is: node_modules/<package>.
85
+ const main = createRequire(import.meta.url).resolve(module[0]);
86
+ const marker = join("node_modules", module[0]);
87
+ return join(main.slice(0, main.lastIndexOf(marker) + marker.length), module[1]);
88
+ }
89
+ catch {
90
+ return undefined;
91
+ }
92
+ }
@@ -0,0 +1,6 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { Served } from "./page.ts";
3
+ /** Answers the page's requests about the game: true when the request was one. */
4
+ export declare function answerGame(request: IncomingMessage, response: ServerResponse, url: URL, served: Served): boolean;
5
+ /** The chat calls a tool: when nobody heard the player before, the AI's clock, held since, starts now, with the time it was held given back. */
6
+ export declare function chatCalls(served: Served): void;