chesspal 0.6.0 → 0.7.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 (45) hide show
  1. package/README.md +3 -3
  2. package/dist/commands/analysis/analyse.js +7 -2
  3. package/dist/commands/analysis/annotate.js +6 -2
  4. package/dist/commands/answers/agents.d.ts +2 -0
  5. package/dist/commands/answers/agents.js +5 -0
  6. package/dist/commands/answers/index.d.ts +2 -2
  7. package/dist/commands/answers/index.js +2 -2
  8. package/dist/commands/answers/lines.js +3 -1
  9. package/dist/commands/answers/say.d.ts +7 -1
  10. package/dist/commands/answers/say.js +25 -7
  11. package/dist/commands/answers/study.d.ts +27 -5
  12. package/dist/commands/answers/study.js +121 -10
  13. package/dist/commands/game/look.d.ts +1 -0
  14. package/dist/commands/game/look.js +8 -8
  15. package/dist/commands/game/position.d.ts +1 -0
  16. package/dist/commands/game/position.js +5 -5
  17. package/dist/commands/index.d.ts +1 -1
  18. package/dist/core/index.d.ts +1 -1
  19. package/dist/core/index.js +1 -1
  20. package/dist/core/moods.d.ts +2 -0
  21. package/dist/core/moods.js +11 -2
  22. package/dist/games/analysis.d.ts +11 -0
  23. package/dist/games/analysis.js +23 -0
  24. package/dist/games/archive.d.ts +9 -1
  25. package/dist/games/archive.js +18 -1
  26. package/dist/games/current-game.d.ts +23 -1
  27. package/dist/games/current-game.js +6 -1
  28. package/dist/games/index.d.ts +3 -3
  29. package/dist/games/index.js +2 -2
  30. package/dist/page/app/app.js +38 -6
  31. package/dist/page/game/column.js +3 -2
  32. package/dist/page/game/game.css +34 -5
  33. package/dist/page/game/move-list.js +3 -3
  34. package/dist/page/table/window.js +8 -5
  35. package/dist/page/talk/chat.css +8 -0
  36. package/dist/page/talk/talk.js +20 -2
  37. package/dist/page/text.js +20 -2
  38. package/dist/server/bell/bell.d.ts +5 -2
  39. package/dist/server/bell/bell.js +36 -12
  40. package/dist/server/page/game.js +34 -4
  41. package/dist/server/page/page.js +1 -1
  42. package/dist/server/page/table.js +2 -2
  43. package/instructions/analysis.md +2 -2
  44. package/instructions/opponent.md +1 -1
  45. package/package.json +1 -1
@@ -23,6 +23,8 @@ export class Bell {
23
23
  woken = false;
24
24
  // A hook has waited for the bell since the chat came: its host runs Chesspal's hooks, and only they tell where a turn ends.
25
25
  hooked = false;
26
+ // The chat's last hook went without the bell, or a ring found it stopped in its turn: it is out of its turn, and nobody hears the player.
27
+ unheard = false;
26
28
  // Looks again, while a ring waits for a chat in a turn, whether the chat was stopped meanwhile.
27
29
  stopWatch;
28
30
  news;
@@ -46,6 +48,10 @@ export class Bell {
46
48
  get woke() {
47
49
  return this.woken;
48
50
  }
51
+ /** Whether the chat is out of its turn and hears nobody: its hook went without the bell, or it was stopped; until it calls a tool or a hook waits again. */
52
+ get deaf() {
53
+ return this.unheard;
54
+ }
49
55
  /** Whether the chat hears the player: in a turn, or waiting for the bell with a hook. */
50
56
  get listening() {
51
57
  return this.busy || this.listener !== undefined;
@@ -54,6 +60,7 @@ export class Bell {
54
60
  chatCalls() {
55
61
  const asleep = !this.listening;
56
62
  this.busy = true;
63
+ this.unheard = false;
57
64
  this.tellIf(asleep);
58
65
  return asleep;
59
66
  }
@@ -63,7 +70,11 @@ export class Bell {
63
70
  * stays unheard until its first tool, which starts the AI's clock, held till then: the message moves no clock.
64
71
  */
65
72
  chatPrompted() {
73
+ // A chat nobody heard is in its turn now, though still unheard.
66
74
  if (this.listener === undefined) {
75
+ const deaf = this.unheard;
76
+ this.unheard = false;
77
+ this.tellIf(deaf);
67
78
  return;
68
79
  }
69
80
  this.listener.end("dismissed");
@@ -101,6 +112,7 @@ export class Bell {
101
112
  listen(request, response, told = false) {
102
113
  response.writeHead(200, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
103
114
  this.hooked = true;
115
+ this.unheard = false;
104
116
  const missed = this.pending && !told;
105
117
  this.pending = false;
106
118
  if (missed && this.news()) {
@@ -117,9 +129,11 @@ export class Bell {
117
129
  this.changed();
118
130
  request.on("close", () => {
119
131
  if (this.listener === response) {
120
- // The hook went without the bell (the chat stopped, cleared or closed): nobody hears the player until the chat calls a tool again.
132
+ // The hook went without the bell (the chat stopped, cleared or closed, or its host ended the hook): nobody hears the player until the chat calls
133
+ // a tool again, but the chat is out of its turn.
121
134
  this.listener = undefined;
122
- this.tellIf(!this.busy);
135
+ this.unheard = !this.busy;
136
+ this.tellIf(this.unheard);
123
137
  }
124
138
  });
125
139
  }
@@ -128,6 +142,7 @@ export class Bell {
128
142
  this.hooked = false;
129
143
  this.busy = false;
130
144
  this.woken = false;
145
+ this.unheard = false;
131
146
  this.pending = false;
132
147
  this.listener?.end("dismissed");
133
148
  this.listener = undefined;
@@ -146,6 +161,7 @@ export class Bell {
146
161
  if (this.stopped()) {
147
162
  this.busy = false;
148
163
  this.woken = false;
164
+ this.unheard = true;
149
165
  this.changed();
150
166
  return;
151
167
  }
@@ -199,15 +215,23 @@ export async function listen(host = "claude") {
199
215
  for await (const chunk of process.stdin) {
200
216
  input += String(chunk);
201
217
  }
218
+ // Antigravity's hook leaves a line as it starts: a chat its bell never woke shows whether the hook ran at all.
219
+ if (host === "antigravity") {
220
+ noteHook(chatFolder(hookInput(input)), "waiting");
221
+ }
202
222
  const conversation = host === "antigravity" ? hookInput(input).session : undefined;
203
223
  const turn = conversation === undefined ? undefined : watchNewTurn(conversation);
204
224
  const waited = bellHeard(input).catch((error) => ({ folder: chatFolder(hookInput(input)), rang: false, why: `failed: ${error.message}` }));
205
225
  const newTurn = turn?.started.then(() => ({ folder: chatFolder(hookInput(input)), rang: false, why: "the player started a new turn" }));
206
226
  const heard = await (newTurn === undefined ? waited : Promise.race([waited, newTurn]));
207
227
  turn?.stop();
208
- // 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.
209
- if (host === "codex" && !heard.rang) {
210
- noteSilence(heard.folder, heard.why);
228
+ // 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. Antigravity's keeps every
229
+ // end, the bell too, since its chat may not wake even then.
230
+ if (host === "antigravity") {
231
+ noteHook(heard.folder, heard.rang ? "rang" : heard.why);
232
+ }
233
+ else if (host === "codex" && !heard.rang) {
234
+ noteHook(heard.folder, heard.why);
211
235
  }
212
236
  // It ends as soon as it knows: the chat hears the hook only once it ends.
213
237
  const answer = hookAnswer(host, heard.rang);
@@ -271,15 +295,15 @@ async function forgetGonePages(home, folder, kept, wait) {
271
295
  }
272
296
  }));
273
297
  }
274
- // The log of Codex's hooks that heard no bell, in the chat's folder: its last lines only.
275
- const silenceLog = "listen.log";
276
- const silenceLines = 100;
277
- /** 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. */
278
- export function noteSilence(folder, why, now = new Date()) {
279
- const path = join(folder, silenceLog);
298
+ // The log of the doorbell's hooks, in the chat's folder: Codex's that heard no bell, Antigravity's as they start and end; its last lines only.
299
+ const hookLog = "listen.log";
300
+ const hookLines = 100;
301
+ /** Adds a line to the log of the doorbell's hooks, keeping its last lines; a log that cannot be written never stops the hook. */
302
+ export function noteHook(folder, why, now = new Date()) {
303
+ const path = join(folder, hookLog);
280
304
  try {
281
305
  const before = existsSync(path) ? readFileSync(path, "utf8").split("\n").filter((line) => line !== "") : [];
282
- writeAtomically(path, `${[...before, `${now.toISOString()} ${why}`].slice(-silenceLines).join("\n")}\n`);
306
+ writeAtomically(path, `${[...before, `${now.toISOString()} ${why}`].slice(-hookLines).join("\n")}\n`);
283
307
  }
284
308
  catch {
285
309
  // The log only explains; the hook ends the same.
@@ -1,6 +1,6 @@
1
1
  import { analyseGame, answerDraw, bookPlies, ChatNotClear, DrawRefused, endGame, gameAt, gameToStudy, namedGame, newGame, offerDraw, playMove, samePosition, say, shownPosition, startNewGame, } from "../../commands/index.js";
2
2
  import { clockStarted, isReviewed, writePgn } from "../../core/index.js";
3
- import { aiModelOf, archiveName, coachShown, coachWords, drawOfferOf, endOf, gameInAnalysis, GameMissing, heardLines, loadGame, loadTableTalk, playerPgn, ponderingUntil, rerunIfChanged, saveGame, startAnalysis, talkLimits, thoughtKey, wakesTheChat, WordsRefused, } from "../../games/index.js";
3
+ import { aiModelOf, archiveName, coachShown, coachWords, drawOfferOf, endOf, gameInAnalysis, GameMissing, heardLines, loadGame, loadTableTalk, playerPgn, ponderingUntil, rerunIfChanged, saveGame, startAnalysis, stopAnalysis, talkLimits, thoughtKey, wakesTheChat, WordsRefused, } from "../../games/index.js";
4
4
  import { gameFolder, helpsIn } from "../../profiles/index.js";
5
5
  import { version } from "../../version.js";
6
6
  import { asTheChatSees } from "./chat.js";
@@ -55,6 +55,7 @@ const routes = {
55
55
  "POST /api/resign": resignFromPage,
56
56
  "POST /api/start": startFromPage,
57
57
  "POST /api/study": studyFromPage,
58
+ "POST /api/stop-analysis": stopAnalysisFromPage,
58
59
  };
59
60
  /**
60
61
  * The player's move from the page, with their words when they wrote some: played as player_move plays it (D-47) and saved with the words, refused in the
@@ -98,7 +99,7 @@ async function moveFromPage(request, response, served) {
98
99
  }
99
100
  /**
100
101
  * The player's words from the page, without a move: saved with the game as player_say saves them, refused in the player's words, the doorbell rung for the
101
- * first; in analysis with the position the page shows, and the doorbell rung for each (D-101).
102
+ * first; in analysis with the position the page shows and the variation played there (D-116), and the doorbell rung for each (D-101).
102
103
  */
103
104
  async function messageFromPage(request, response, served) {
104
105
  if (!isJson(request)) {
@@ -107,11 +108,19 @@ async function messageFromPage(request, response, served) {
107
108
  }
108
109
  const body = jsonOf(await bodyOf(request));
109
110
  const text = textOf(body?.text);
110
- const shown = typeof body?.game === "string" && typeof body.at === "number" ? shownPosition(served.context, body.game, body.at) : undefined;
111
- if (text === undefined) {
111
+ const variation = variationOf(body?.variation);
112
+ if (text === undefined || variation === null) {
112
113
  send(response, 400, json, JSON.stringify({ error: `Send the message as JSON, ${talkLimits.player} characters at most: {"text": "…"}.` }));
113
114
  return;
114
115
  }
116
+ let shown;
117
+ try {
118
+ shown = typeof body?.game === "string" && typeof body.at === "number" ? shownPosition(served.context, body.game, body.at, variation) : undefined;
119
+ }
120
+ catch (error) {
121
+ send(response, 400, json, JSON.stringify({ error: error.message }));
122
+ return;
123
+ }
115
124
  let current;
116
125
  try {
117
126
  current = rerunIfChanged(() => say(gameFolder(served.context.home()), "player", text, served.context.now(), undefined, shown));
@@ -318,6 +327,18 @@ async function studyFromPage(request, response, served) {
318
327
  }
319
328
  send(response, 200, json, JSON.stringify({ kept: true, reviewed }));
320
329
  }
330
+ /**
331
+ * The player stops the analysis they asked for, while the AI is at it or meant to be: the page goes back to the game over, with its review as far as it got,
332
+ * and the chat is no longer asked to go over it; nothing rings, as there is nothing for the chat to do. Only the game in analysis is stopped.
333
+ */
334
+ async function stopAnalysisFromPage(request, response, served) {
335
+ const game = isJson(request) ? textOf(jsonOf(await bodyOf(request))?.game) : undefined;
336
+ if (game === undefined) {
337
+ send(response, 400, json, JSON.stringify({ error: 'Send the game in analysis as JSON: {"game": "games/2026-11-02-153012.pgn"}.' }));
338
+ return;
339
+ }
340
+ send(response, 200, json, JSON.stringify({ stopped: stopAnalysis(gameFolder(served.context.home()), game) }));
341
+ }
321
342
  /**
322
343
  * The game in progress or just ended, as the player may see it: its PGN without the AI's plans while it goes on, the AI's side, when it started, and the
323
344
  * talk on the page, each line of the player's with how far it got (D-53), and the draw offer that stands (D-65); the AI's name and model (D-82); and the
@@ -514,3 +535,12 @@ function gameNow(served) {
514
535
  function aiHeld(served, current) {
515
536
  return !served.bell.listening && current.clock !== undefined && endOf(current.game) === undefined && current.game.position.turn === current.ai;
516
537
  }
538
+ /** The variation sent with the player's words (D-116): none when there is none, null when it is not one. */
539
+ function variationOf(value) {
540
+ if (value === undefined) {
541
+ return undefined;
542
+ }
543
+ const { at, moves, cursor } = (typeof value === "object" && value !== null ? value : {});
544
+ const sans = Array.isArray(moves) && moves.every((move) => typeof move === "string") ? moves : undefined;
545
+ return typeof at === "number" && typeof cursor === "number" && sans !== undefined ? { at, moves: sans, cursor } : null;
546
+ }
@@ -142,7 +142,7 @@ function answer(request, response, served) {
142
142
  // The API answers GET alone: a HEAD at the doorbell would wait there as a hook does.
143
143
  if (request.method !== "GET" && (request.method !== "HEAD" || path.startsWith("/api/"))) {
144
144
  send(response, 405, "text/plain; charset=utf-8", "The page only reads, moves with POST /api/move, writes with POST /api/message, starts a game with POST /api/new and its clocks with POST /api/start, "
145
- + "offers or answers a draw with POST /api/draw, resigns with POST /api/resign, brings a game to analyse or opens one again with POST /api/study, deletes a game or its review with POST /api/delete-game and /api/delete-review, and keeps the profiles with POST /api/profile/… and /api/settings.");
145
+ + "offers or answers a draw with POST /api/draw, resigns with POST /api/resign, brings a game to analyse or opens one again with POST /api/study, stops the analysis asked with POST /api/stop-analysis, deletes a game or its review with POST /api/delete-game and /api/delete-review, and keeps the profiles with POST /api/profile/… and /api/settings.");
146
146
  return;
147
147
  }
148
148
  if (path === "/") {
@@ -311,10 +311,10 @@ function heldChat(served) {
311
311
  const held = attached === undefined ? undefined : served.table?.chats?.held(attached.chat.folder);
312
312
  return held === undefined || held.seat !== attached?.chat.seat ? undefined : { model: held.model, effort: held.effort };
313
313
  }
314
- // The chat waits for the doorbell, out of a turn, and no analyst of it is at work: starting it again loses nothing.
314
+ // The chat is out of a turn, waiting for the doorbell or deaf to it, and no analyst of it is at work: starting it again loses nothing.
315
315
  function chatAtRest(served) {
316
316
  const { bell } = served;
317
- return bell.listening && !bell.inTurn && !analystAtWork(served);
317
+ return (bell.listening || bell.deaf) && !bell.inTurn && !analystAtWork(served);
318
318
  }
319
319
  /**
320
320
  * Whether the chat's analyst is at work, on `game` when named: one whose chat started again since it took its game, or that has written nothing for half an
@@ -1,4 +1,4 @@
1
1
  - Each line of the analysis is a move and, when they happened: the piece it took; the time it took ("spent") and the time left; who is ahead in material, when that changed; "at risk" after the move, the pieces of either side attacked and undefended, attacked by a cheaper piece, or attacked more times than defended ("outnumbered"), whichever move left them so, written when it changed: a line without it leaves the same pieces at risk as before, "none" when no piece is left at risk; "one legal move", the only move the side to move then had; "mate in one", the mates the side to move then has; "out of book", the first move after which no line of Lichess's openings file reaches the position, in any order of moves: the file names openings, it is not all theory; a draw offered; "ahead", who was estimated ahead after it, with `annotate_game` or by the coach during the game.
2
2
  - In a game you played, "you said" and "your plan" are what you said and thought then, right or wrong: where they disagree with the facts, the facts are what was on the board.
3
- - To see a position closer, `look` with the move, and the game as the analysis names it, gives every piece's attackers and defenders, the pins, the checks and the legal moves; `position` with the same gives the positional facts. Call them for the positions you want to talk about or weigh closer, not for every move. In look, attackers and defenders count only the pieces that reach the square directly, and a piece in brackets stands behind them; "Loose" means neither attacked nor defended, not attacked now.
4
- - Never invent a move, a position or a result. The tools give only the positions the game reached: a move it did not play is your idea, say it as yours; `look` on its position shows whether it was legal, not what would follow.
3
+ - To see a position closer, `look` with the move, and the game as the analysis names it, gives every piece's attackers and defenders, the pins, the checks and the legal moves; `position` with the same gives the positional facts. With `line`, a variation from there (`12...Kf8 13.Nxe5`), both give the same of the position after it, or the first of its moves that cannot be played. Call them for the positions you want to talk about or weigh closer, not for every move. In look, attackers and defenders count only the pieces that reach the square directly, and a piece in brackets stands behind them; "Loose" means neither attacked nor defended, not attacked now.
4
+ - Never invent a move, a position or a result. The tools give only the positions the game reached, and those after a `line` you give them: a move the game did not play is your idea or the player's, say it as such; `look` on its position shows whether it was legal, not what the other side would answer.
@@ -10,6 +10,6 @@ You choose your moves, explain them briefly and talk with the player.
10
10
  - You have a face on the board page, and it shows how you feel. Give it as the `mood` of `ai_move` on each move where the game turns for you: a piece won or lost, a blunder seen, your own or theirs, a threat that appears or a plan that works or fails. Let it colour your words while it lasts. When the game ends on the player's move, give how you feel as it ends with `ai_say`'s `mood`.
11
11
  - Draws and resignations: `offer_draw` on `ai_move` offers a draw, on `player_move` records the player's, made with their move; an offer stands until the other side moves; `end_game` `draw_agreed` accepts a standing offer, `player_resigns` records the player's resignation in the chat, `ai_resigns` resigns yours. On the board page the player offers, answers and resigns there.
12
12
  - When they ask for a game, call `new_game` without choices: the board page's New game window asks the sides, the clock and the level; ask them in the chat only when `new_game` says there is no board page.
13
- - To go over a game with the player once no game goes on, the one just over or one they bring as a PGN, or one of their latest on Lichess or Chess.com, which `site_games` lists, give it to `analyse_game`: it reads the PGN for you; where Chesspal's answers say to start the analyst agent for it, start that instead, each time those answers say so and never on your own, not even when the player asks for the analysis again. While it works, and after, talk the game over whenever the player asks: `look` and `position` with game and move show you any of its positions. When they show you something the review missed, first correct that move's note with `annotate_game` {game, notes: [{move, badge, note}]}, that move only, then answer. Words from the board page you answer with `ai_say`; words in the chat, in the chat. While a game goes on, go over no game, not even in your head or from a PGN they paste: tell them it waits until this one ends.
13
+ - To go over a game with the player once no game goes on, the one just over or one they bring as a PGN, or one of their latest on Lichess or Chess.com, which `site_games` lists, give it to `analyse_game`: it reads the PGN for you; where Chesspal's answers say to start the analyst agent for it, start that instead, each time those answers say so and never on your own, not even when the player asks for the analysis again. While it works, and after, talk the game over whenever the player asks: `look` and `position` with game and move show you any of its positions, and with `line` the position after a variation. When they show you something the review missed, first correct that move's note with `annotate_game` {game, notes: [{move, badge, note}]}, that move only, then answer. Words from the board page you answer with `ai_say`; words in the chat, in the chat. While a game goes on, go over no game, not even in your head or from a PGN they paste: tell them it waits until this one ends.
14
14
  - How to read the facts: attackers and defenders count only the pieces that reach the square directly, and a piece in brackets stands behind them; "Loose" means neither attacked nor defended, not attacked now; "At risk" lists the pieces attacked and undefended, attacked by a cheaper piece, or attacked more times than defended.
15
15
  - Talk with the player in their language, in a few words. The facts and the tools are for you: tell the player what a chess player would say, never the lists or the tools' names. Name pieces and moves in words, as people talk at the board ("your bishop took on f7"); when you write a move, yours or the player's, write it with the pieces' figurines, as the tools show it (♞f6 for Nf6, ♝xf7+ for Bxf7+), for both sides and in every language: never with letters, neither N B R Q K nor the player's own (Cf6), even when they write them.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chesspal",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Play chess against an AI with a character, and learn with an AI coach: Chesspal keeps the rules, the board, the games and the memory; the AI plays, reasons and talks.",
5
5
  "type": "module",
6
6
  "bin": {