chesspal 0.2.4 → 0.3.1

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 (184) hide show
  1. package/README.md +11 -12
  2. package/dist/cli/cli.js +83 -3
  3. package/dist/cli/command-line.js +26 -9
  4. package/dist/cli/icon.d.ts +25 -0
  5. package/dist/cli/icon.js +80 -0
  6. package/dist/commands/agents.d.ts +19 -2
  7. package/dist/commands/agents.js +39 -13
  8. package/dist/commands/ai-say.js +3 -4
  9. package/dist/commands/analyse-game.d.ts +4 -0
  10. package/dist/commands/analyse-game.js +46 -13
  11. package/dist/commands/annotate-game.d.ts +2 -0
  12. package/dist/commands/annotate-game.js +40 -18
  13. package/dist/commands/answer-lines.d.ts +2 -0
  14. package/dist/commands/answer-lines.js +13 -3
  15. package/dist/commands/coach.js +4 -2
  16. package/dist/commands/context.d.ts +11 -1
  17. package/dist/commands/context.js +5 -0
  18. package/dist/commands/delete-review.js +9 -5
  19. package/dist/commands/game-summary.d.ts +2 -0
  20. package/dist/commands/game-summary.js +4 -0
  21. package/dist/commands/index.d.ts +3 -2
  22. package/dist/commands/index.js +7 -4
  23. package/dist/commands/init.js +2 -4
  24. package/dist/commands/instructions.d.ts +9 -0
  25. package/dist/commands/instructions.js +30 -0
  26. package/dist/commands/lead-input.d.ts +1 -1
  27. package/dist/commands/lead-input.js +7 -4
  28. package/dist/commands/page-link.js +6 -5
  29. package/dist/commands/past-games.d.ts +1 -1
  30. package/dist/commands/past-games.js +12 -5
  31. package/dist/commands/player-move.js +2 -3
  32. package/dist/commands/profiles.d.ts +23 -9
  33. package/dist/commands/profiles.js +76 -35
  34. package/dist/commands/ratings.d.ts +8 -0
  35. package/dist/commands/ratings.js +45 -0
  36. package/dist/commands/registry.d.ts +1 -1
  37. package/dist/commands/registry.js +3 -2
  38. package/dist/commands/say.d.ts +1 -1
  39. package/dist/commands/say.js +1 -1
  40. package/dist/commands/status.d.ts +3 -1
  41. package/dist/commands/status.js +46 -27
  42. package/dist/commands/study.js +8 -5
  43. package/dist/core/index.d.ts +1 -1
  44. package/dist/core/index.js +1 -1
  45. package/dist/core/review.d.ts +2 -0
  46. package/dist/core/review.js +5 -1
  47. package/dist/games/ai-model.d.ts +4 -2
  48. package/dist/games/ai-model.js +10 -1
  49. package/dist/games/analysis.d.ts +26 -3
  50. package/dist/games/analysis.js +41 -6
  51. package/dist/games/archive.d.ts +6 -3
  52. package/dist/games/archive.js +13 -5
  53. package/dist/games/current-game.d.ts +10 -1
  54. package/dist/games/current-game.js +49 -15
  55. package/dist/games/files.d.ts +5 -0
  56. package/dist/games/files.js +42 -1
  57. package/dist/games/index.d.ts +3 -3
  58. package/dist/games/index.js +3 -3
  59. package/dist/home/data-folder.d.ts +2 -0
  60. package/dist/home/data-folder.js +5 -1
  61. package/dist/home/index.d.ts +1 -1
  62. package/dist/home/index.js +1 -1
  63. package/dist/host-files/agents.d.ts +2 -2
  64. package/dist/host-files/agents.js +12 -6
  65. package/dist/host-files/host-files.d.ts +5 -3
  66. package/dist/host-files/host-files.js +15 -8
  67. package/dist/host-files/index.d.ts +2 -2
  68. package/dist/host-files/index.js +2 -2
  69. package/dist/page/app/app.js +49 -20
  70. package/dist/page/app/title.js +17 -0
  71. package/dist/page/avatars/torretta.js +4 -4
  72. package/dist/page/dialogs/analyses.js +4 -4
  73. package/dist/page/dialogs/dialog.js +4 -1
  74. package/dist/page/dialogs/dialogs.css +84 -10
  75. package/dist/page/dialogs/profiles.js +96 -43
  76. package/dist/page/game/column.js +5 -1
  77. package/dist/page/game/plies.js +7 -4
  78. package/dist/page/guide/blocks.js +67 -0
  79. package/dist/page/guide/en.md +66 -0
  80. package/dist/page/guide/foot.js +18 -0
  81. package/dist/page/guide/guide.css +200 -0
  82. package/dist/page/guide/guide.js +89 -0
  83. package/dist/page/guide/it.md +66 -0
  84. package/dist/page/guide/markdown.js +67 -0
  85. package/dist/page/helps/helps.css +7 -10
  86. package/dist/page/layout/favicon.svg +13 -0
  87. package/dist/page/layout/header.js +3 -3
  88. package/dist/page/layout/icon.icns +0 -0
  89. package/dist/page/layout/icon.ico +0 -0
  90. package/dist/page/layout/icon.png +0 -0
  91. package/dist/page/layout/layout.css +85 -4
  92. package/dist/page/layout/window.js +45 -0
  93. package/dist/page/main.js +4 -1
  94. package/dist/page/players/card.js +1 -1
  95. package/dist/page/profile/profile.js +14 -9
  96. package/dist/page/profile/settings.js +9 -0
  97. package/dist/page/style.css +2 -0
  98. package/dist/page/table/table.css +123 -0
  99. package/dist/page/table/window.js +130 -0
  100. package/dist/page/talk/chat.css +23 -5
  101. package/dist/page/talk/talk.css +38 -26
  102. package/dist/page/talk/talk.js +6 -4
  103. package/dist/page/talk/voice.js +35 -9
  104. package/dist/page/text.js +131 -17
  105. package/dist/page/theme.css +7 -0
  106. package/dist/profiles/index.d.ts +2 -1
  107. package/dist/profiles/index.js +2 -1
  108. package/dist/profiles/profiles.d.ts +10 -15
  109. package/dist/profiles/profiles.js +30 -15
  110. package/dist/profiles/ratings.d.ts +35 -0
  111. package/dist/profiles/ratings.js +61 -0
  112. package/dist/server/bell.d.ts +24 -3
  113. package/dist/server/bell.js +84 -10
  114. package/dist/server/caller.d.ts +2 -1
  115. package/dist/server/caller.js +3 -2
  116. package/dist/server/mcp.d.ts +7 -3
  117. package/dist/server/mcp.js +100 -52
  118. package/dist/server/page/analyses.d.ts +2 -0
  119. package/dist/server/page/analyses.js +20 -5
  120. package/dist/server/page/attach.d.ts +4 -0
  121. package/dist/server/page/attach.js +112 -0
  122. package/dist/server/page/chat.d.ts +65 -0
  123. package/dist/server/page/chat.js +87 -0
  124. package/dist/server/page/events.d.ts +7 -0
  125. package/dist/server/page/events.js +20 -0
  126. package/dist/server/page/files.js +10 -1
  127. package/dist/server/page/game.js +43 -23
  128. package/dist/server/page/page.d.ts +51 -7
  129. package/dist/server/page/page.js +74 -48
  130. package/dist/server/page/profiles.d.ts +5 -0
  131. package/dist/server/page/profiles.js +26 -10
  132. package/dist/server/page/table.d.ts +58 -0
  133. package/dist/server/page/table.js +210 -0
  134. package/dist/server/table/browser.d.ts +6 -0
  135. package/dist/server/table/browser.js +48 -0
  136. package/dist/server/table/hosts/chats.d.ts +7 -0
  137. package/dist/server/table/hosts/chats.js +34 -0
  138. package/dist/server/table/hosts/find.d.ts +2 -0
  139. package/dist/server/table/hosts/find.js +20 -0
  140. package/dist/server/table/hosts/hosts.d.ts +34 -0
  141. package/dist/server/table/hosts/hosts.js +49 -0
  142. package/dist/server/table/hosts/index.d.ts +3 -0
  143. package/dist/server/table/hosts/index.js +4 -0
  144. package/dist/server/table/hosts/play.d.ts +29 -0
  145. package/dist/server/table/hosts/play.js +180 -0
  146. package/dist/server/table/hosts/seat.d.ts +33 -0
  147. package/dist/server/table/hosts/seat.js +80 -0
  148. package/dist/server/table/hosts/terminal.d.ts +6 -0
  149. package/dist/server/table/hosts/terminal.js +63 -0
  150. package/dist/server/table/icon/index.d.ts +32 -0
  151. package/dist/server/table/icon/index.js +83 -0
  152. package/dist/server/table/icon/linux.d.ts +5 -0
  153. package/dist/server/table/icon/linux.js +47 -0
  154. package/dist/server/table/icon/macos.d.ts +3 -0
  155. package/dist/server/table/icon/macos.js +38 -0
  156. package/dist/server/table/icon/windows.d.ts +3 -0
  157. package/dist/server/table/icon/windows.js +48 -0
  158. package/dist/server/table/index.d.ts +6 -0
  159. package/dist/server/table/index.js +7 -0
  160. package/dist/server/table/launch.d.ts +19 -0
  161. package/dist/server/table/launch.js +78 -0
  162. package/dist/server/table/link.d.ts +10 -0
  163. package/dist/server/table/link.js +231 -0
  164. package/dist/server/table/table.d.ts +35 -0
  165. package/dist/server/table/table.js +94 -0
  166. package/dist/sites/chesscom.d.ts +4 -0
  167. package/dist/sites/chesscom.js +25 -0
  168. package/dist/sites/choice.d.ts +17 -0
  169. package/dist/sites/choice.js +13 -0
  170. package/dist/sites/index.d.ts +2 -0
  171. package/dist/sites/index.js +4 -0
  172. package/dist/sites/lichess.d.ts +4 -0
  173. package/dist/sites/lichess.js +32 -0
  174. package/dist/sites/request.d.ts +11 -0
  175. package/dist/sites/request.js +15 -0
  176. package/dist/sites/sites.d.ts +6 -0
  177. package/dist/sites/sites.js +33 -0
  178. package/instructions/analysis.md +1 -1
  179. package/instructions/analyst-agent.md +1 -1
  180. package/instructions/coach.md +2 -1
  181. package/instructions/opponent.md +6 -6
  182. package/instructions/ponder.md +1 -0
  183. package/instructions/start.md +1 -0
  184. package/package.json +1 -1
@@ -29,16 +29,32 @@ export declare class Bell {
29
29
  ring(): void;
30
30
  /** 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. */
31
31
  listen(request: IncomingMessage, response: ServerResponse): void;
32
+ /** The chat left the table (D-104): its process ended, and nobody is in a turn or waits for the bell; a ring no hook heard is no longer news for anyone. */
33
+ chatLeft(): void;
32
34
  private tellIf;
33
35
  }
34
- /** 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. */
35
- export declare function bellProbe(folder: string, open?: boolean): BellProbe;
36
+ /**
37
+ * Whose page answers at a port, for the hook to find its own: the chat's session, the player's folder, whether a browser has the page open, Chesspal's
38
+ * version, and whether the table serves it (D-104), with the session and the folder of the chat attached to it, never its own process's.
39
+ */
40
+ export declare function bellProbe(folder: string, open?: boolean, table?: TableChat): BellProbe;
41
+ /** The chat attached to the table, as its probe says it: its session, null for none or for Codex, and its folder; neither while no chat is attached. */
42
+ export interface TableChat {
43
+ session: string | null;
44
+ chat?: string;
45
+ }
36
46
  /** What a page says of itself at `/api/bell?probe`. */
37
47
  export interface BellProbe {
38
48
  session: string | null;
39
49
  folder: string;
40
50
  open: boolean;
51
+ version?: string;
52
+ table?: true;
53
+ /** The folder of the chat attached to the table. */
54
+ chat?: string;
41
55
  }
56
+ /** What the page at a port of this computer says of itself; undefined when none answers there. */
57
+ export declare function pageAt(port: number): Promise<BellProbe | undefined>;
42
58
  /** 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. */
43
59
  export declare function pagePorts(folder: string): number[];
44
60
  /** What Codex's Stop hook answers to wake the chat: its turn goes on with the doorbell's line. */
@@ -62,7 +78,12 @@ export type BellHeard = {
62
78
  rang: false;
63
79
  why: string;
64
80
  };
65
- /** Waits at the page of the hook's chat, as waitForBell does, and says what it heard. */
81
+ /**
82
+ * Waits at the page of the hook's chat, as waitForBell does, and says what it heard. The chat may be attached to the table (D-104), beside another chat
83
+ * of its folder that serves its own page: the table is one more page to choose from, unless the ports to try are given (Priya).
84
+ */
66
85
  export declare function bellHeard(input: string, ports?: number[]): Promise<BellHeard>;
67
86
  /** 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. */
68
87
  export declare function noteSilence(folder: string, why: string, now?: Date): void;
88
+ /** Whether two paths name the same folder: without regard to case on Windows. */
89
+ export declare function samePath(one: string, other: string): boolean;
@@ -5,6 +5,8 @@ import { existsSync, readFileSync } from "node:fs";
5
5
  import { get } from "node:http";
6
6
  import { join, resolve } from "node:path";
7
7
  import { writeAtomically } from "../games/index.js";
8
+ import { dataFolder, tableFolder } from "../home/index.js";
9
+ import { version } from "../version.js";
8
10
  /** What the chat hears from the doorbell: one line for whatever the player did, never what, only where to read it. */
9
11
  export const bellLine = "The player acted on the board page: call status to see it.";
10
12
  /**
@@ -84,15 +86,32 @@ export class Bell {
84
86
  }
85
87
  });
86
88
  }
89
+ /** The chat left the table (D-104): its process ended, and nobody is in a turn or waits for the bell; a ring no hook heard is no longer news for anyone. */
90
+ chatLeft() {
91
+ this.busy = false;
92
+ this.woken = false;
93
+ this.pending = false;
94
+ this.listener?.end("dismissed");
95
+ this.listener = undefined;
96
+ this.changed();
97
+ }
87
98
  tellIf(changed) {
88
99
  if (changed) {
89
100
  this.changed();
90
101
  }
91
102
  }
92
103
  }
93
- /** 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. */
94
- export function bellProbe(folder, open = false) {
95
- return { session: process.env.CLAUDE_CODE_SESSION_ID ?? null, folder: resolve(folder), open };
104
+ /**
105
+ * Whose page answers at a port, for the hook to find its own: the chat's session, the player's folder, whether a browser has the page open, Chesspal's
106
+ * version, and whether the table serves it (D-104), with the session and the folder of the chat attached to it, never its own process's.
107
+ */
108
+ export function bellProbe(folder, open = false, table) {
109
+ const session = table === undefined ? process.env.CLAUDE_CODE_SESSION_ID ?? null : table.session;
110
+ return { session, folder: resolve(folder), open, version, ...(table === undefined ? {} : { table: true, ...(table.chat === undefined ? {} : { chat: table.chat }) }) };
111
+ }
112
+ /** What the page at a port of this computer says of itself; undefined when none answers there. */
113
+ export async function pageAt(port) {
114
+ return probeOf(await body(port, "/api/bell?probe", probeWait));
96
115
  }
97
116
  /** 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. */
98
117
  export function pagePorts(folder) {
@@ -133,13 +152,18 @@ export async function listen(codex = false) {
133
152
  export async function waitForBell(input, ports) {
134
153
  return (await bellHeard(input, ports)).rang;
135
154
  }
136
- /** Waits at the page of the hook's chat, as waitForBell does, and says what it heard. */
155
+ /**
156
+ * Waits at the page of the hook's chat, as waitForBell does, and says what it heard. The chat may be attached to the table (D-104), beside another chat
157
+ * of its folder that serves its own page: the table is one more page to choose from, unless the ports to try are given (Priya).
158
+ */
137
159
  export async function bellHeard(input, ports) {
138
160
  const hook = hookInput(input);
139
161
  const folder = chatFolder(hook);
140
162
  const tried = ports ?? pagePorts(folder);
141
- const probes = await Promise.all(tried.map(async (port) => ({ port, said: probeOf(await body(port, "/api/bell?probe", probeWait)) })));
142
- const ours = probes.flatMap(({ port, said }) => (said !== undefined && samePath(said.folder, folder) ? [{ port, said }] : []));
163
+ const [probes, table] = await Promise.all([Promise.all(tried.map(async (port) => ({ port, said: await pageAt(port) }))), ports === undefined ? tableOf(folder, hook.session) : undefined]);
164
+ const pages = probes.flatMap(({ port, said }) => (said !== undefined && samePath(said.folder, folder) ? [{ port, said }] : []));
165
+ // A chat opened in the table's own folder finds the table among the pages of its folder already.
166
+ const ours = table === undefined || pages.some(({ port }) => port === table.port) ? pages : [...pages, table];
143
167
  const port = chatPage(ours, hook.session)?.port;
144
168
  if (port === undefined) {
145
169
  const others = probes.flatMap((one) => (one.said === undefined ? [] : [`${one.port} is ${one.said.folder}'s`]));
@@ -152,6 +176,50 @@ export async function bellHeard(input, ports) {
152
176
  }
153
177
  // How long a port may take to say whose page it is: one of this computer answers at once.
154
178
  const probeWait = 1000;
179
+ /**
180
+ * The table, when the chat attached to it is the hook's: of its folder, or of its session; none when the data folder cannot be found, or no table has
181
+ * this chat. Finding the data folder costs some tens of milliseconds on Windows, off the AI's clock: the hook waits after the turn.
182
+ */
183
+ async function tableOf(folder, session) {
184
+ let ports;
185
+ try {
186
+ ports = pagePorts(tableFolder(serverHome(folder) ?? dataFolder()));
187
+ }
188
+ catch {
189
+ return undefined;
190
+ }
191
+ const probes = await Promise.all(ports.map(async (port) => ({ port, said: await pageAt(port) })));
192
+ return probes.find((one) => one.said?.table === true && ((one.said.chat !== undefined && samePath(one.said.chat, folder)) || (session !== undefined && one.said.session === session)));
193
+ }
194
+ /**
195
+ * The data folder the chat's server is given in its folder's settings, as CHESSPAL_HOME in a playtest: the hook runs in the host's environment, not the
196
+ * server's (Federico). Claude Code's .mcp.json first, then Codex's config.
197
+ */
198
+ function serverHome(folder) {
199
+ const chosen = [claudeHome(folder), codexHome(folder)].find((one) => one !== undefined && one.trim() !== "");
200
+ return chosen === undefined ? undefined : resolve(chosen.trim());
201
+ }
202
+ function claudeHome(folder) {
203
+ try {
204
+ const config = JSON.parse(readFileSync(join(folder, ".mcp.json"), "utf8"));
205
+ const home = config.mcpServers?.chesspal?.env?.CHESSPAL_HOME;
206
+ return typeof home === "string" ? home : undefined;
207
+ }
208
+ catch {
209
+ return undefined;
210
+ }
211
+ }
212
+ // The setting as init keeps it in Codex's table: a basic string, whose escapes are JSON's for a path, or a literal one.
213
+ function codexHome(folder) {
214
+ try {
215
+ const config = readFileSync(join(folder, ".codex", "config.toml"), "utf8");
216
+ const [, basic, literal] = /\bCHESSPAL_HOME\s*=\s*(?:("(?:[^"\\]|\\.)*")|'([^']*)')/.exec(config) ?? [];
217
+ return basic === undefined ? literal : JSON.parse(basic);
218
+ }
219
+ catch {
220
+ return undefined;
221
+ }
222
+ }
155
223
  // The log of Codex's hooks that heard no bell, in the chat's folder: its last lines only.
156
224
  const silenceLog = "listen.log";
157
225
  const silenceLines = 100;
@@ -193,15 +261,21 @@ function chatPage(pages, session) {
193
261
  function probeOf(said) {
194
262
  try {
195
263
  const parsed = said === undefined ? undefined : JSON.parse(said);
196
- return parsed === undefined || typeof parsed.folder !== "string"
197
- ? undefined
198
- : { session: typeof parsed.session === "string" ? parsed.session : null, folder: parsed.folder, open: parsed.open === true };
264
+ return parsed === undefined || typeof parsed.folder !== "string" ? undefined : {
265
+ session: typeof parsed.session === "string" ? parsed.session : null,
266
+ folder: parsed.folder,
267
+ open: parsed.open === true,
268
+ ...(typeof parsed.version === "string" ? { version: parsed.version } : {}),
269
+ ...(parsed.table === true ? { table: true } : {}),
270
+ ...(typeof parsed.chat === "string" ? { chat: parsed.chat } : {}),
271
+ };
199
272
  }
200
273
  catch {
201
274
  return undefined;
202
275
  }
203
276
  }
204
- function samePath(one, other) {
277
+ /** Whether two paths name the same folder: without regard to case on Windows. */
278
+ export function samePath(one, other) {
205
279
  return process.platform === "win32" ? resolve(one).toLowerCase() === resolve(other).toLowerCase() : resolve(one) === resolve(other);
206
280
  }
207
281
  /** The body a page of this computer answers with, once it ends; undefined when it does not answer, or not with 200. */
@@ -6,11 +6,12 @@ export interface Client {
6
6
  }
7
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
8
  export declare function callerOf(meta: Record<string, unknown> | undefined, client: Client | undefined, folder: string, wait?: number): Promise<Caller | undefined>;
9
- /** Who in Claude Code or Codex made a tool call: the chat itself, or one of its agents by its type. */
9
+ /** Who in Claude Code or Codex made a tool call: the chat itself, or one of its agents by its type, with the run it is, when the host tells it. */
10
10
  export type CallingAgent = {
11
11
  chat: true;
12
12
  } | {
13
13
  agent: string;
14
+ run?: string;
14
15
  };
15
16
  /**
16
17
  * Who in Claude Code or Codex 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
@@ -39,8 +39,9 @@ export async function callingAgent(meta, folder, wait = 50) {
39
39
  if (found.file === transcript) {
40
40
  return { chat: true };
41
41
  }
42
+ // Each agent Claude Code starts writes a file of its own: its name tells two runs of the same agent apart.
42
43
  const type = objectOf(textOf(found.file.replace(/\.jsonl$/, ".meta.json")))?.agentType;
43
- return typeof type === "string" ? { agent: type } : undefined;
44
+ return typeof type === "string" ? { agent: type, run: basename(found.file, ".jsonl") } : undefined;
44
45
  }
45
46
  /** Whether a call comes from one of Codex's agents, as its metadata say: its own process starts no board page (D-98). */
46
47
  export function fromCodexAgent(meta) {
@@ -110,7 +111,7 @@ async function codexCaller(turn, wait) {
110
111
  const start = headOf(codexSession(thread));
111
112
  const role = /"agent_role":"([^"]+)"/.exec(start)?.[1];
112
113
  if (role !== undefined) {
113
- const caller = start.includes('"forked_from_id":"') ? { chat: true } : { agent: role };
114
+ const caller = start.includes('"forked_from_id":"') ? { chat: true } : { agent: role, run: thread };
114
115
  codexThreads.set(thread, caller);
115
116
  return caller;
116
117
  }
@@ -1,6 +1,9 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
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. */
3
+ /**
4
+ * What the host reads as the server starts, before any tool: the lines that start a chat, `instructions` then `status`, and the rule of every move (D-27,
5
+ * D-102). Short, since hosts cut them or copy them into every tool.
6
+ */
4
7
  export declare const serverInstructions: string;
5
8
  /** The MCP server for an AI host: every command of the registry as a tool, with its description and parameters (D-5). */
6
9
  export declare function createMcpServer(context?: Context, list?: AnyCommand[], beforeCall?: (meta: Record<string, unknown> | undefined) => Promise<void>): McpServer;
@@ -10,9 +13,10 @@ export declare function createMcpServer(context?: Context, list?: AnyCommand[],
10
13
  */
11
14
  export declare function startMcpServer(context?: Context, list?: AnyCommand[]): Promise<void>;
12
15
  /**
13
- * The board page started once, before the chat's first call, the status every chat opens with, which then gives its address: the process of a Codex
16
+ * The board page started once, before the chat's first call, the status every chat opens with, which then gives its address; started `again` when the
17
+ * table the chat was attached to is lost, at once, off the AI's clock, or at the next call when a table to go to has come (D-104). The process of a Codex
14
18
  * agent, which has one of its own, starts none, which would stand beside the chat's and could keep the doorbell from it (D-98, Priya).
15
19
  */
16
- export declare function lazyPage(start: () => Promise<void>): (meta: Record<string, unknown> | undefined) => Promise<void>;
20
+ export declare function lazyPage(start: (again: (now?: boolean) => void) => Promise<void>): (meta: Record<string, unknown> | undefined) => Promise<void>;
17
21
  /** The opponent's name for the page, from the host's (D-97): another host than Claude's and Codex is unnamed. */
18
22
  export declare function opponentOf(host: string | undefined): string | undefined;
@@ -1,19 +1,23 @@
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).
1
+ // The MCP server an AI host starts: every command as a tool (D-5), and the board page: the table's, which the chat attaches to, or one it serves itself
2
+ // for as long as it lasts (D-47, D-104).
2
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
5
  import { z } from "zod";
5
6
  import { aiMove, commands, defaultContext, inputProblems, isBoolean, PositionChanged } from "../commands/index.js";
6
7
  import { aiNameOf } from "../core/index.js";
7
- import { endOf, loadGame, noteAiHost, noteAiModel } from "../games/index.js";
8
- import { agentTools, hasAgent } from "../host-files/index.js";
8
+ import { endOf, loadGame, noteAiHost, noteAiModel, rerunIfChanged } from "../games/index.js";
9
+ import { agentTools, hasAgent, startInstructions } from "../host-files/index.js";
9
10
  import { gameFolder } from "../profiles/index.js";
10
11
  import { version } from "../version.js";
11
12
  import { pagePorts } from "./bell.js";
12
13
  import { allowedCaller, callerOf, callingAgent, fromCodexAgent } from "./caller.js";
13
14
  import { chatHasRead, startPage } from "./page/page.js";
14
- /** 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. */
15
- 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 "
16
- + "chat. Every move goes through Chesspal's tools: never keep the board in your head or invent a position.";
15
+ import { attachTable, tableToJoin } from "./table/index.js";
16
+ /**
17
+ * What the host reads as the server starts, before any tool: the lines that start a chat, `instructions` then `status`, and the rule of every move (D-27,
18
+ * D-102). Short, since hosts cut them or copy them into every tool.
19
+ */
20
+ export const serverInstructions = startInstructions();
17
21
  /** The MCP server for an AI host: every command of the registry as a tool, with its description and parameters (D-5). */
18
22
  export function createMcpServer(context = defaultContext(), list = commands, beforeCall) {
19
23
  const server = new McpServer({ name: "chesspal", version }, { instructions: serverInstructions });
@@ -24,28 +28,8 @@ export function createMcpServer(context = defaultContext(), list = commands, bef
24
28
  context.host ??= () => server.server.getClientVersion()?.name;
25
29
  const forSomeHosts = [];
26
30
  for (const command of list.filter((each) => each.cliOnly !== true)) {
27
- const tool = server.registerTool(command.name, toolConfig(command), async (input, extra) => {
28
- await beforeCall?.(extra._meta);
29
- const codexAgent = fromCodexAgent(extra._meta);
30
- const calling = command.byAgents !== undefined || codexAgent ? await callingAgent(extra._meta, context.folder) : undefined;
31
- // An agent's tool called by the chat, or by another agent, would give what only that agent may see (Priya).
32
- if (command.byAgents !== undefined && !allowedCaller(calling, command.byAgents)) {
33
- // It names no agent to start: the chat that asked would start one itself (Priya).
34
- 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 };
35
- }
36
- // Codex gives its agents every tool: one of Chesspal's keeps to its own, and never plays or talks for the opponent (D-98, Federico).
37
- const own = calling !== undefined && "agent" in calling ? agentTools(calling.agent) : undefined;
38
- if (codexAgent && own !== undefined && !own.includes(command.name)) {
39
- return { content: [{ type: "text", text: `${command.name} is not yours to call: you work only with ${own.join(", ")}. Nothing changed.` }], isError: true };
40
- }
41
- // Each call knows who makes it, for the commands that write it down (D-81).
42
- const caller = () => callerOf(extra._meta, server.server.getClientVersion(), context.folder);
43
- const result = await track(runTool(command, input, context, caller, extra._meta));
44
- if (command === aiMove && result.isError !== true) {
45
- noteTheAi(context, caller);
46
- }
47
- return result;
48
- });
31
+ // A call runs from its start, the page started before it too: the server that leaves waits for it.
32
+ const tool = server.registerTool(command.name, toolConfig(command), (input, extra) => track(callTool(command, input, context, server, extra._meta, beforeCall)));
49
33
  if (command.hosts !== undefined) {
50
34
  tool.disable();
51
35
  forSomeHosts.push({ hosts: command.hosts, tool });
@@ -73,21 +57,35 @@ export async function startMcpServer(context = defaultContext(), list = commands
73
57
  };
74
58
  process.stdin.on("end", leave);
75
59
  process.stdin.on("close", leave);
76
- const pageFirst = lazyPage(async () => {
77
- context.page = await pageOf(context, () => opponentOf(context.host?.()));
60
+ let page;
61
+ // The page served before, of its own, closes when the chat goes to a table.
62
+ const pageFirst = lazyPage(async (again) => {
63
+ await page?.close();
64
+ page = await pageOf(context, () => opponentOf(context.host?.()), again);
65
+ context.page = page?.url;
78
66
  });
79
67
  const server = createMcpServer(context, list, pageFirst);
80
68
  await server.connect(new StdioServerTransport());
81
69
  }
82
70
  /**
83
- * The board page started once, before the chat's first call, the status every chat opens with, which then gives its address: the process of a Codex
71
+ * The board page started once, before the chat's first call, the status every chat opens with, which then gives its address; started `again` when the
72
+ * table the chat was attached to is lost, at once, off the AI's clock, or at the next call when a table to go to has come (D-104). The process of a Codex
84
73
  * agent, which has one of its own, starts none, which would stand beside the chat's and could keep the doorbell from it (D-98, Priya).
85
74
  */
86
75
  export function lazyPage(start) {
87
76
  let page;
77
+ const begin = () => {
78
+ // What an earlier start asks is no reason to drop the page this one serves.
79
+ const started = start((now = false) => {
80
+ if (page === started) {
81
+ page = now ? begin() : undefined;
82
+ }
83
+ });
84
+ return started;
85
+ };
88
86
  return async (meta) => {
89
87
  if (!fromCodexAgent(meta)) {
90
- page ??= start();
88
+ page ??= begin();
91
89
  await page;
92
90
  }
93
91
  };
@@ -96,19 +94,24 @@ export function lazyPage(start) {
96
94
  export function opponentOf(host) {
97
95
  return aiNameOf(host);
98
96
  }
99
- /** 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. */
100
- async function pageOf(context, opponent) {
97
+ /**
98
+ * The board page of the player's folder: the table's, when one of this version takes the chat (D-104), else served by this process, which looks for a
99
+ * table after each tool; without it the game goes on in the chat. It starts `again`: at once when the table is lost, at the next call when one is found.
100
+ */
101
+ async function pageOf(context, opponent, again) {
101
102
  try {
102
- const { url, heard, chatCalls, open, askNewGame } = await startPage(context, opponent);
103
+ const table = await attachTable(context, () => again(true));
104
+ const page = table ?? await startPage(context, { opponent });
103
105
  // Words the AI is given change no file: the pages hear of them from the commands.
104
- context.heard = heard;
105
- context.calls = chatCalls;
106
- context.pageOpen = open;
107
- context.askNewGame = askNewGame;
108
- if (!pagePorts(context.folder).includes(Number(new URL(url).port))) {
109
- 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`);
106
+ context.heard = page.heard;
107
+ context.calls = page.chatCalls;
108
+ context.toolEnded = table === undefined ? lookForTable(context, again) : page.toolEnded;
109
+ context.pageOpen = page.open;
110
+ context.askNewGame = page.askNewGame;
111
+ if (table === undefined && !pagePorts(context.folder).includes(Number(new URL(page.url).port))) {
112
+ process.stderr.write(`The board page is at ${page.url}, off its usual ports: the doorbell cannot find it, so moves on the page do not wake the chat.\n`);
110
113
  }
111
- return url;
114
+ return page;
112
115
  }
113
116
  catch (error) {
114
117
  // The host keeps what the server writes on stderr in its logs.
@@ -116,6 +119,27 @@ async function pageOf(context, opponent) {
116
119
  return undefined;
117
120
  }
118
121
  }
122
+ /**
123
+ * After each tool of a chat that serves its own page, a look for a table of its version with no chat at it, off the AI's clock: one started since, by
124
+ * `chesspal` or the icon, takes the chat at its next tool, and its page then closes (Priya).
125
+ */
126
+ function lookForTable(context, again) {
127
+ let looking = false;
128
+ return () => {
129
+ if (looking) {
130
+ return;
131
+ }
132
+ looking = true;
133
+ setImmediate(() => {
134
+ void tableToJoin(context).then((found) => {
135
+ looking = false;
136
+ if (found) {
137
+ again();
138
+ }
139
+ });
140
+ });
141
+ };
142
+ }
119
143
  /**
120
144
  * Who played the AI's move, its host and model, read once the move has gone back to the chat, and kept in memory (D-82, D-97): the AI's card on the
121
145
  * page shows it, and the game's 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.
@@ -125,19 +149,41 @@ function noteTheAi(context, caller) {
125
149
  setImmediate(() => {
126
150
  // Nothing that fails here may stop the server: the move is played and answered.
127
151
  caller().then((found) => {
152
+ const folder = gameFolder(context.home());
128
153
  const host = context.host?.();
129
- if (host !== undefined && host.trim() !== "") {
130
- noteAiHost(gameFolder(context.home()), host);
131
- }
132
- if (found === undefined || found.hostOnly === true) {
133
- return;
134
- }
135
- if (noteAiModel(gameFolder(context.home()), { model: found.annotator, ...(found.effort === undefined ? {} : { effort: found.effort }) })) {
154
+ const hostChanged = host !== undefined && host.trim() !== "" && noteAiHost(folder, host);
155
+ const modelChanged = found !== undefined && found.hostOnly !== true
156
+ && noteAiModel(folder, { model: found.annotator, ...(found.effort === undefined ? {} : { effort: found.effort }) });
157
+ // The pages, and the table the chat is attached to, hear of it now: the move's copy went before (Federico).
158
+ if (hostChanged || modelChanged) {
136
159
  context.heard?.();
137
160
  }
138
161
  }).catch(() => undefined);
139
162
  });
140
163
  }
164
+ /** A tool called: the page started first, at the chat's first call; refused to an agent it is not for (D-89, D-98); and who played as the AI noted after a move. */
165
+ async function callTool(command, input, context, server, meta, beforeCall) {
166
+ await beforeCall?.(meta);
167
+ const codexAgent = fromCodexAgent(meta);
168
+ const calling = command.byAgents !== undefined || codexAgent ? await callingAgent(meta, context.folder) : undefined;
169
+ // An agent's tool called by the chat, or by another agent, would give what only that agent may see (Priya).
170
+ if (command.byAgents !== undefined && !allowedCaller(calling, command.byAgents)) {
171
+ // It names no agent to start: the chat that asked would start one itself (Priya).
172
+ 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 };
173
+ }
174
+ // Codex gives its agents every tool: one of Chesspal's keeps to its own, and never plays or talks for the opponent (D-98, Federico).
175
+ const own = calling !== undefined && "agent" in calling ? agentTools(calling.agent) : undefined;
176
+ if (codexAgent && own !== undefined && !own.includes(command.name)) {
177
+ return { content: [{ type: "text", text: `${command.name} is not yours to call: you work only with ${own.join(", ")}. Nothing changed.` }], isError: true };
178
+ }
179
+ // Each call knows who makes it, for the commands that write it down (D-81).
180
+ const caller = () => callerOf(meta, server.server.getClientVersion(), context.folder);
181
+ const result = await runTool(command, input, context, caller, meta);
182
+ if (command === aiMove && result.isError !== true) {
183
+ noteTheAi(context, caller);
184
+ }
185
+ return result;
186
+ }
141
187
  // The tool calls still running, which the server waits for before it leaves.
142
188
  const running = new Set();
143
189
  function track(call) {
@@ -160,10 +206,12 @@ async function runTool(command, input, context, caller, meta) {
160
206
  let refused = false;
161
207
  try {
162
208
  if (chat) {
163
- context.calls?.();
209
+ await context.calls?.();
164
210
  }
165
- const who = agent !== undefined ? { agent } : calling !== undefined ? { chat: true } : {};
166
- const result = await command.run(checkedInput(command, input), { ...context, caller, ...who });
211
+ const run = calling !== undefined && "run" in calling ? calling.run : undefined;
212
+ const who = agent !== undefined ? { agent, ...(run === undefined ? {} : { agentRun: run }) } : calling !== undefined ? { chat: true } : {};
213
+ // A game another process changed under the call is read again, and the call made once more.
214
+ const result = await rerunIfChanged(() => command.run(checkedInput(command, input), { ...context, caller, ...who }));
167
215
  return { content: [{ type: "text", text: command.format(result, context) }] };
168
216
  }
169
217
  catch (error) {
@@ -12,6 +12,8 @@ export interface AnalysisItem {
12
12
  readonly opening?: string;
13
13
  readonly moves: number;
14
14
  readonly reviewed: boolean;
15
+ /** A review begun and not finished, its notes or the analyst that took it, which the page lets the player write again or delete (Stefano). */
16
+ readonly begun?: true;
15
17
  /** Who reviewed it (D-81). */
16
18
  readonly annotator?: AiModel;
17
19
  /** Where a game loaded from elsewhere was played, as its Site tag says: the player's name there makes it theirs (D-93). */
@@ -1,6 +1,6 @@
1
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";
2
+ import { aiModelIn, annotatorOf, isReviewed, leadOf, reviewOf } from "../../core/index.js";
3
+ import { archivedGame, archivedGames, gameInAnalysis, gameStemOf, keptGame, keptGames } from "../../games/index.js";
4
4
  import { gameFolder } from "../../profiles/index.js";
5
5
  import { bodyOf, isJson, jsonOf, runRoute, sendJson, textOf } from "./http.js";
6
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. */
@@ -9,7 +9,8 @@ export function analysesOf(home) {
9
9
  // By the time in their names, which orders kept and played games together.
10
10
  const stamp = (name) => gameStemOf(name) ?? "";
11
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)));
12
+ const taken = gameInAnalysis(folder)?.analyst?.game;
13
+ return names.flatMap((name) => itemOf(name, () => name.startsWith("study/") ? keptGame(folder, name) : archivedGame(folder, name), taken === name));
13
14
  }
14
15
  /** 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
16
  export function answerAnalyses(request, response, url, served) {
@@ -33,7 +34,7 @@ export function answerAnalyses(request, response, url, served) {
33
34
  return true;
34
35
  }
35
36
  const deletions = { "/api/delete-game": deleteGame, "/api/delete-review": deleteReview };
36
- function itemOf(name, read) {
37
+ function itemOf(name, read, taken) {
37
38
  let record;
38
39
  try {
39
40
  record = read();
@@ -42,6 +43,10 @@ function itemOf(name, read) {
42
43
  // A game that cannot be read stays out of the list.
43
44
  return [];
44
45
  }
46
+ // A game closed before any move has nothing to analyse; a position brought to study without moves stays, to open again or delete.
47
+ if (record.moves.length === 0 && name.startsWith("games/")) {
48
+ return [];
49
+ }
45
50
  const { tags } = record;
46
51
  const date = tags.Date === undefined || tags.Date.includes("?") ? undefined : tags.Date;
47
52
  const opening = bookOpening(record)?.name;
@@ -49,6 +54,8 @@ function itemOf(name, read) {
49
54
  const site = tags.Site === undefined || /^[?\-\s]*$/.test(tags.Site) ? undefined : tags.Site;
50
55
  const signed = annotatorOf(record);
51
56
  const annotator = signed === undefined ? undefined : { model: signed.annotator, ...(signed.effort === undefined ? {} : { effort: signed.effort }) };
57
+ const reviewed = isReviewed(record);
58
+ const begun = !reviewed && (taken || reviewBegun(record));
52
59
  return [{
53
60
  name,
54
61
  white: tags.White ?? "?",
@@ -58,8 +65,16 @@ function itemOf(name, read) {
58
65
  result: tags.Result ?? "*",
59
66
  ...(opening === undefined ? {} : { opening }),
60
67
  moves: Math.ceil(record.moves.length / 2),
61
- reviewed: isReviewed(record),
68
+ reviewed,
69
+ ...(begun ? { begun: true } : {}),
62
70
  ...(annotator === undefined ? {} : { annotator }),
63
71
  ...(site === undefined ? {} : { site }),
64
72
  }];
65
73
  }
74
+ /** Whether a game holds part of a review on its moves: a note, a badge but the book's, which Chesspal puts there, or who is ahead. */
75
+ function reviewBegun(record) {
76
+ return record.moves.some(({ comment }) => {
77
+ const review = reviewOf(comment);
78
+ return review?.note !== undefined || (review?.badge !== undefined && review.badge !== "book") || leadOf(comment) !== undefined;
79
+ });
80
+ }
@@ -0,0 +1,4 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { Served } from "./page.ts";
3
+ /** Answers the chat's requests to the table, under /api/chat; false for any other, and for every request to a page a chat serves itself. */
4
+ export declare function answerChat(request: IncomingMessage, response: ServerResponse, url: URL, served: Served): boolean;