chesspal 0.3.2 → 0.3.4

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 (70) hide show
  1. package/README.md +49 -10
  2. package/dist/commands/analysis/analyse.d.ts +4 -1
  3. package/dist/commands/analysis/analyse.js +46 -12
  4. package/dist/commands/analysis/index.d.ts +1 -0
  5. package/dist/commands/analysis/index.js +2 -1
  6. package/dist/commands/analysis/site-games.d.ts +27 -0
  7. package/dist/commands/analysis/site-games.js +84 -0
  8. package/dist/commands/answers/agents.d.ts +1 -1
  9. package/dist/commands/answers/agents.js +8 -4
  10. package/dist/commands/index.d.ts +1 -1
  11. package/dist/commands/index.js +3 -2
  12. package/dist/commands/profiles/index.d.ts +1 -1
  13. package/dist/commands/profiles/index.js +1 -1
  14. package/dist/commands/profiles/profiles.d.ts +3 -1
  15. package/dist/commands/profiles/profiles.js +2 -1
  16. package/dist/commands/registry/context.d.ts +4 -1
  17. package/dist/commands/registry/registry.d.ts +2 -0
  18. package/dist/core/accounts.d.ts +11 -0
  19. package/dist/core/accounts.js +14 -0
  20. package/dist/core/index.d.ts +1 -0
  21. package/dist/core/index.js +1 -0
  22. package/dist/page/app/app.js +37 -13
  23. package/dist/page/app/served.js +8 -3
  24. package/dist/page/dialogs/analyses.js +3 -13
  25. package/dist/page/dialogs/dialogs.css +102 -0
  26. package/dist/page/dialogs/outcome.js +13 -0
  27. package/dist/page/dialogs/site-games.js +71 -0
  28. package/dist/page/dialogs/studied.js +3 -14
  29. package/dist/page/dialogs/study.js +30 -13
  30. package/dist/page/game/column.js +1 -1
  31. package/dist/page/game/game.css +25 -2
  32. package/dist/page/guide/en.md +1 -0
  33. package/dist/page/guide/it.md +1 -0
  34. package/dist/page/helps/helps.css +100 -23
  35. package/dist/page/helps/helps.js +7 -7
  36. package/dist/page/table/window.js +6 -5
  37. package/dist/page/talk/talk.js +2 -2
  38. package/dist/page/text.js +44 -4
  39. package/dist/server/bell/bell.js +14 -1
  40. package/dist/server/mcp/mcp.js +2 -2
  41. package/dist/server/page/analyses.d.ts +5 -0
  42. package/dist/server/page/analyses.js +18 -1
  43. package/dist/server/page/attach.js +8 -3
  44. package/dist/server/page/game.js +8 -5
  45. package/dist/server/page/page.js +17 -2
  46. package/dist/sites/chesscom.d.ts +1 -1
  47. package/dist/sites/chesscom.js +1 -1
  48. package/dist/sites/games/chesscom.d.ts +4 -0
  49. package/dist/sites/games/chesscom.js +54 -0
  50. package/dist/sites/games/game.d.ts +34 -0
  51. package/dist/sites/games/game.js +64 -0
  52. package/dist/sites/games/games.d.ts +14 -0
  53. package/dist/sites/games/games.js +22 -0
  54. package/dist/sites/games/index.d.ts +2 -0
  55. package/dist/sites/games/index.js +3 -0
  56. package/dist/sites/games/lichess.d.ts +4 -0
  57. package/dist/sites/games/lichess.js +45 -0
  58. package/dist/sites/index.d.ts +2 -1
  59. package/dist/sites/index.js +5 -3
  60. package/dist/sites/lichess.d.ts +1 -1
  61. package/dist/sites/lichess.js +1 -1
  62. package/dist/sites/request/index.d.ts +1 -0
  63. package/dist/sites/request/index.js +2 -0
  64. package/dist/sites/{request.d.ts → request/request.d.ts} +7 -0
  65. package/dist/sites/request/request.js +37 -0
  66. package/dist/sites/sites.d.ts +1 -1
  67. package/dist/sites/sites.js +1 -1
  68. package/instructions/opponent.md +1 -1
  69. package/package.json +1 -1
  70. package/dist/sites/request.js +0 -15
@@ -238,12 +238,19 @@
238
238
  padding: 3px 6px;
239
239
  }
240
240
 
241
- /* The coach's words at the top of the card (D-88): its judgment of the player's last move, then its hint, folded until they open it */
241
+ /* The coach's words at the top of the card (D-88), tinted rows as the tactics': each judgment in its badge's colour, the hint in the accent behind a bulb */
242
242
  .helps-card .coach {
243
243
  display: flex;
244
244
  flex-direction: column;
245
- gap: 6px;
246
- font-size: 14px;
245
+ gap: 3px;
246
+ font-size: 13px;
247
+ }
248
+
249
+ .helps-card .coach .judged,
250
+ .helps-card .coach .coach-hint {
251
+ --row: var(--accent);
252
+ border-radius: 6px;
253
+ background: color-mix(in srgb, var(--row) 10%, transparent);
247
254
  }
248
255
 
249
256
  .coach-hint summary {
@@ -257,6 +264,48 @@
257
264
  display: none;
258
265
  }
259
266
 
267
+ /* A row's head: its icon, its words in ink, the sign of a line that opens on the right */
268
+ .helps-card .coach .head,
269
+ .helps-card .coach summary {
270
+ display: flex;
271
+ align-items: center;
272
+ gap: 6px;
273
+ padding: 3px 8px 3px 6px;
274
+ border-radius: 6px;
275
+ color: var(--ink);
276
+ }
277
+
278
+ .helps-card .coach summary {
279
+ cursor: pointer;
280
+ list-style: none;
281
+ }
282
+
283
+ .helps-card .coach summary:hover,
284
+ .helps-card .coach details[open] > summary {
285
+ background: color-mix(in srgb, var(--row) 16%, transparent);
286
+ }
287
+
288
+ .helps-card .coach .row-icon {
289
+ display: grid;
290
+ place-items: center;
291
+ flex: none;
292
+ width: 16px;
293
+ height: 16px;
294
+ color: var(--row);
295
+ }
296
+
297
+ .board-map .coach-hint .row-icon {
298
+ display: none;
299
+ }
300
+
301
+ /* A finger needs a taller line (Zoe) */
302
+ @media (pointer: coarse) {
303
+ .helps-card .coach .head,
304
+ .helps-card .coach summary {
305
+ padding-block: 7px;
306
+ }
307
+ }
308
+
260
309
  /* The same sign on both, so that on a phone, with no pointer, they read as lines that open (Zoe) */
261
310
  .helps-card .coach summary::after,
262
311
  .board-map .coach-hint summary::after {
@@ -265,12 +314,31 @@
265
314
  color: var(--soft);
266
315
  }
267
316
 
317
+ .helps-card .coach summary::after {
318
+ margin-left: auto;
319
+ }
320
+
268
321
  .helps-card .coach details[open] > summary::after,
269
322
  .board-map .coach-hint[open] > summary::after {
270
323
  content: "▾";
271
324
  }
272
325
 
273
- .coach-hint .note {
326
+ /* The words opened, under the head's text */
327
+ .helps-card .coach .note {
328
+ display: block;
329
+ margin: 0;
330
+ padding: 2px 8px 6px 28px;
331
+ color: var(--say-ink);
332
+ font-size: 13px;
333
+ line-height: 1.4;
334
+ }
335
+
336
+ .helps-card .coach .legend {
337
+ margin: 0;
338
+ padding: 0 8px 6px 28px;
339
+ }
340
+
341
+ .board-map .coach-hint .note {
274
342
  display: block;
275
343
  margin-top: 4px;
276
344
  color: var(--say-ink);
@@ -472,7 +540,8 @@
472
540
  color: var(--row);
473
541
  }
474
542
 
475
- .help-rows .row-icon svg {
543
+ .help-rows .row-icon svg,
544
+ .coach-hint .row-icon svg {
476
545
  width: 14px;
477
546
  height: 14px;
478
547
  fill: none;
@@ -553,41 +622,49 @@
553
622
  gap: 10px;
554
623
  }
555
624
 
556
- /* The three phases in a row, joined by a thin line; the current one in a pill */
625
+ /* The three phases in a row of arrows, each set in the next: the current one full in the accent, those gone by in its tint, those to come unlit */
557
626
  .phases {
558
627
  display: flex;
559
- align-items: center;
560
628
  margin: 0;
561
629
  padding: 0;
562
630
  list-style: none;
563
631
  font-size: 12px;
632
+ line-height: 22px;
633
+ text-align: center;
564
634
  color: var(--soft);
565
635
  }
566
636
 
637
+ .phases li {
638
+ flex: 1;
639
+ padding: 0 6px 0 12px;
640
+ background: color-mix(in srgb, var(--soft) 14%, transparent);
641
+ white-space: nowrap;
642
+ clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 50%, calc(100% - 8px) 100%, 0 100%, 8px 50%);
643
+ }
644
+
567
645
  .phases li + li {
568
- flex: 1 1 auto;
569
- display: flex;
570
- align-items: center;
571
- gap: 6px;
572
- margin-left: 6px;
646
+ margin-left: -5px;
573
647
  }
574
648
 
575
- .phases li + li::before {
576
- content: "";
577
- flex: 1;
578
- height: 1px;
579
- background: var(--line);
649
+ .phases li:first-child {
650
+ padding-left: 8px;
651
+ border-radius: 5px 0 0 5px;
652
+ clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 50%, calc(100% - 8px) 100%, 0 100%);
580
653
  }
581
654
 
582
- .phases li span {
583
- padding: 1px 9px;
584
- white-space: nowrap;
655
+ .phases li:last-child {
656
+ border-radius: 0 5px 5px 0;
657
+ clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%, 8px 50%);
585
658
  }
586
659
 
587
- .phases li[aria-current="step"] span {
588
- border-radius: 999px;
589
- background: color-mix(in srgb, var(--accent) 20%, transparent);
660
+ .phases li.done {
661
+ background: color-mix(in srgb, var(--accent) 24%, transparent);
590
662
  color: var(--ink);
663
+ }
664
+
665
+ .phases li[aria-current="step"] {
666
+ background: var(--accent);
667
+ color: var(--on-accent);
591
668
  font-weight: 700;
592
669
  }
593
670
 
@@ -41,12 +41,12 @@ export function BareButton({ bare, onBare, text }) {
41
41
  return (_jsx(HoldButton, { on: bare, onHold: onBare, title: text.helpsCard.bareHint, label: text.helpsCard.bare, kind: "bare-button", children: _jsxs("svg", { viewBox: "0 0 16 16", "aria-hidden": "true", children: [_jsx("path", { d: "M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8Z" }), _jsx("circle", { cx: "8", cy: "8", r: "2" }), _jsx("path", { d: "M2.5 2.5l11 11" })] }) }));
42
42
  }
43
43
  /**
44
- * The coach's hint, a line that opens on its words and the legend of what it draws (D-88): in the helps' card, and on a phone under the board, beside the
45
- * map, where its drawings are in sight (Zoe).
44
+ * The coach's hint, a line that opens on its words and the legend of what it draws (D-88): in the helps' card a tinted row behind a bulb, as the tactics',
45
+ * and on a phone under the board, beside the map, where its drawings are in sight (Zoe).
46
46
  */
47
47
  export function HintLine({ hint, open, onOpen, text }) {
48
48
  const words = text.helpsCard;
49
- return (_jsxs("details", { class: "coach-hint", open: open, onToggle: (event) => onOpen(event.currentTarget.open), children: [_jsx("summary", { children: open ? words.hideHint : words.showHint }), _jsx("span", { class: "note", children: _jsx(Marked, { text: hint.text }) }), hint.arrows === undefined && hint.squares === undefined ? null : _jsx(Legend, { review: hint, words: words.drawn })] }));
49
+ return (_jsxs("details", { class: "coach-hint", open: open, onToggle: (event) => onOpen(event.currentTarget.open), children: [_jsxs("summary", { children: [_jsxs(RowIcon, { children: [_jsx("path", { d: "M5.5 10.5C4.3 9.7 3.5 8.4 3.5 7a4.5 4.5 0 0 1 9 0c0 1.4-.8 2.7-2 3.5V12h-5Z" }), _jsx("path", { d: "M6.5 14.2h3" })] }), open ? words.hideHint : words.showHint] }), _jsx("span", { class: "note", children: _jsx(Marked, { text: hint.text }) }), hint.arrows === undefined && hint.squares === undefined ? null : _jsx(Legend, { review: hint, words: words.drawn })] }));
50
50
  }
51
51
  /** An icon the mouse holds, while what it shows lasts; a finger, or the keyboard, turns it on and off (D-78). */
52
52
  function HoldButton({ on, onHold, title, label, kind, children }) {
@@ -54,11 +54,11 @@ function HoldButton({ on, onHold, title, label, kind, children }) {
54
54
  const mouse = (event) => event instanceof PointerEvent && event.pointerType === "mouse";
55
55
  return (_jsx("button", { type: "button", class: kind === undefined ? "hold-button" : `hold-button ${kind}`, "aria-pressed": on, "aria-label": label, title: title, onPointerDown: (event) => mouse(event) && onHold(true), onPointerUp: (event) => mouse(event) && onHold(false), onPointerLeave: (event) => mouse(event) && onHold(false), onClick: (event) => !mouse(event) && onHold(!on), children: children }));
56
56
  }
57
- /** The coach's judgment of the player's move: its badge and the move in a line, which opens on its note (D-78, D-88). */
57
+ /** The coach's judgment of the player's move: its badge and the move in a row tinted in the badge's colour, which opens on its note (D-78, D-88). */
58
58
  function Judged({ ply, text }) {
59
59
  const { badge, note } = ply.review ?? {};
60
60
  const head = (_jsxs(_Fragment, { children: [badge === undefined ? _jsx(NoteIcon, {}) : _jsx(BadgeIcon, { badge: badge }), _jsxs("b", { children: [_jsxs("span", { class: "move-number", children: [ply.number, ply.side === "white" ? "." : "..."] }), _jsx(Figurines, { san: ply.san, side: ply.side })] }), " ", badge === undefined ? null : text.badges[badge]] }));
61
- return (_jsx("div", { class: `move-note judged ${badge ?? ""}`, children: note === undefined ? _jsx("span", { class: "head", children: head }) : (_jsxs("details", { children: [_jsx("summary", { class: "head", children: head }), _jsx("span", { class: "note", children: _jsx(Marked, { text: note }) })] })) }));
61
+ return (_jsx("div", { class: `move-note judged ${badge ?? ""}`, style: badge === undefined ? undefined : { "--row": `var(--badge-${badge})` }, children: note === undefined ? _jsx("span", { class: "head", children: head }) : (_jsxs("details", { children: [_jsx("summary", { class: "head", children: head }), _jsx("span", { class: "note", children: _jsx(Marked, { text: note }) })] })) }));
62
62
  }
63
63
  /**
64
64
  * The tactics in lines (D-91): one a kind of fact and a side, its count, red when it threatens the player and green when it is their chance; a click opens
@@ -184,9 +184,9 @@ function squaresIn(line) {
184
184
  }
185
185
  return [line.white, line.black].flatMap((cell) => typeof cell === "object" ? cell.filter((item) => /^[a-h][1-8]$/.test(item)) : []);
186
186
  }
187
- /** The game's three phases in a row, the current one in a pill. */
187
+ /** The game's three phases in a row of arrows: the current one full, those gone by lit, those to come not. */
188
188
  function Phases({ current, words }) {
189
- return (_jsx("ol", { class: "phases", children: phases.map((phase) => (_jsx("li", { "aria-current": phase === current ? "step" : undefined, children: _jsx("span", { children: words.phases[phase] }) }, phase))) }));
189
+ return (_jsx("ol", { class: "phases", children: phases.map((phase, at) => (_jsx("li", { class: at < phases.indexOf(current) ? "done" : undefined, "aria-current": phase === current ? "step" : undefined, children: words.phases[phase] }, phase))) }));
190
190
  }
191
191
  /** The squares only one side controls and those both do, in a bar as long as their counts, the player's side on the left; each part's title says it in words. */
192
192
  function ControlBar({ controlled, sides, you, words }) {
@@ -2,8 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
2
2
  import { useEffect, useState } from "preact/hooks";
3
3
  import { Dialog } from "../dialogs/dialog.js";
4
4
  import { checkIcon } from "../dialogs/profiles.js";
5
- /** The chat's window: closed once a chat opens or starts again, the page then saying where it stands. */
6
- export function ChatDialog({ open, table, settings, onChange, name, onClose, text }) {
5
+ /** The chat's window: closed once a chat opens or starts again, the page then saying where it stands; `review` when it opened to have a game analysed. */
6
+ export function ChatDialog({ open, review = false, table, settings, onChange, name, onClose, text }) {
7
7
  const [sending, setSending] = useState(false);
8
8
  const [failed, setFailed] = useState(false);
9
9
  useEffect(() => setFailed(false), [open]);
@@ -25,20 +25,21 @@ export function ChatDialog({ open, table, settings, onChange, name, onClose, tex
25
25
  }
26
26
  };
27
27
  // While a chat opens or waits to start again, the rows wait too, and the note says why.
28
+ const chooseNote = review ? words.reviewChoose : words.choose;
28
29
  const note = installed.length === 0
29
30
  ? words.install(name)
30
31
  : busy
31
- ? chatState(table, name, text) ?? words.choose
32
+ ? chatState(table, name, text) ?? chooseNote
32
33
  : byHand
33
34
  ? words.byHand(name)
34
35
  : table.chat
35
36
  ? words.restartHint
36
- : words.choose;
37
+ : chooseNote;
37
38
  const offersChoices = installed.some((host) => host.models.length > 0 || host.efforts.length > 0);
38
39
  // The chat at the table started with other choices than the ones kept now: its row starts it again with these, else it only closes the window.
39
40
  const started = table.held ? table.started : undefined;
40
41
  const changed = (host) => started !== undefined && (chosen(host, "model", settings) !== (started.model ?? "") || chosen(host, "effort", settings) !== (started.effort ?? ""));
41
- return (_jsxs(Dialog, { open: open, title: words.title, onClose: onClose, text: text, children: [_jsx("p", { class: "note", children: note }), _jsxs("div", { class: "chat-hosts", role: "group", "aria-label": words.title, children: [offersChoices
42
+ return (_jsxs(Dialog, { open: open, title: review ? words.reviewTitle : words.title, onClose: onClose, text: text, children: [_jsx("p", { class: "note", children: note }), _jsxs("div", { class: "chat-hosts", role: "group", "aria-label": review ? words.reviewTitle : words.title, children: [offersChoices
42
43
  ? (_jsxs("div", { class: "chat-head", "aria-hidden": "true", children: [_jsx("span", {}), _jsx("span", { children: words.model }), _jsx("span", { children: words.effort })] }))
43
44
  : null, installed.map((host) => {
44
45
  const atTable = table.chat && table.host === host.name;
@@ -19,7 +19,7 @@ export function AiWords({ words, newestFirst, voice }) {
19
19
  * The AI's word on the move shown of a game in analysis (D-69), in a bubble under the Torretta beside the AI's name, pointing up at it: the move, its badge by name,
20
20
  * and its note when it wrote one; read out as the player steps through the game. The Torretta and the name stay when the move has no word.
21
21
  */
22
- export function AiNote({ ply, opening, closing, summary, reviewing, name, by, mood, voice, text }) {
22
+ export function AiNote({ ply, opening, closing, summary, reviewing, onComplete, name, by, mood, voice, text }) {
23
23
  // On a move of the book without the AI's note, the AI reports what the book says: the opening in the title, the variation under it.
24
24
  const book = ply?.review?.note === undefined ? opening : undefined;
25
25
  const shown = ply?.review ?? (book === undefined ? undefined : { badge: "book" });
@@ -35,7 +35,7 @@ export function AiNote({ ply, opening, closing, summary, reviewing, name, by, mo
35
35
  // The Torretta stays in sight through the whole analysis, over its bubbles, which show only where it wrote something.
36
36
  return (_jsxs("div", { class: "ai-note", "aria-live": "polite", children: [_jsxs("p", { class: "who", children: [_jsx(Torretta, { size: 36, mood: mood }), _jsxs("span", { class: "signed", children: [name, by === undefined ? null : _jsx("small", { children: signature(by.annotator, by.effort) })] })] }), ply === undefined || shown === undefined ? null : (_jsxs("p", { class: `say move-note ${shown.badge ?? ""}`, children: [_jsxs("span", { class: "head", children: [shown.badge === undefined ? _jsx(NoteIcon, {}) : _jsx(BadgeIcon, { badge: shown.badge }), _jsxs("b", { children: [_jsxs("span", { class: "visually-hidden", children: [ply.number, ply.side === "white" ? "." : "...", ply.san] }), _jsxs("span", { "aria-hidden": "true", children: [_jsxs("span", { class: "move-number", children: [ply.number, ply.side === "white" ? "." : "..."] }), _jsx(Figurines, { san: ply.san, side: ply.side })] })] }), " ", title] }), note === undefined ? null : (_jsx("span", { class: "note", children: _jsx(Marked, { text: note }) })), shown.arrows === undefined && shown.squares === undefined ? null : _jsx(Legend, { review: shown, words: text.drawn })] })), summary === undefined ? null : _jsx(GameReview, { by: name, summary: summary, text: text }), closing === undefined ? null : (_jsx("p", { class: "say closing", children: _jsx(Marked, { text: closing }) })), reviewing === true
37
37
  ? (_jsxs("p", { class: "say reviewing", children: [_jsx("span", { class: "spinner", "aria-hidden": "true" }), text.reviewing(name)] }))
38
- : null] }));
38
+ : null, onComplete === undefined ? null : (_jsxs("p", { class: "say incomplete", children: [_jsx("span", { role: "status", children: text.incomplete }), _jsx("button", { type: "button", title: text.completeHint, "aria-label": text.completeHint, onClick: onComplete, children: text.complete })] }))] }));
39
39
  }
40
40
  /** The squares and moves in the AI's words, such as e3, Cxe4, 7.Nd4 or O-O: in bold, as the player looks for them on the board. */
41
41
  export function Marked({ text }) {
package/dist/page/text.js CHANGED
@@ -87,7 +87,7 @@ const english = {
87
87
  saveMoves: "PGN",
88
88
  saveMovesHint: "The moves alone, to open on Lichess or chess.com",
89
89
  noGameNow: "No game in progress",
90
- analysing: "Analysis in progress",
90
+ analysing: "Analysis",
91
91
  badges: {
92
92
  brilliant: "Brilliant",
93
93
  great: "Great move",
@@ -115,6 +115,9 @@ const english = {
115
115
  mated: (side) => `${side === "white" ? "White" : "Black"} is mated`,
116
116
  },
117
117
  reviewing: (name) => `${name} is going over the game…`,
118
+ incomplete: "Analysis not complete",
119
+ complete: "Complete it",
120
+ completeHint: "Complete the analysis",
118
121
  you: "You",
119
122
  ai: "AI",
120
123
  sides: { white: "White", black: "Black" },
@@ -170,6 +173,8 @@ const english = {
170
173
  table: {
171
174
  title: "Who are you playing?",
172
175
  choose: "Choose the chat to play with: it opens in a window of its own, to keep open.",
176
+ reviewTitle: "Who reviews the game?",
177
+ reviewChoose: "Choose the chat that reviews the game: it opens in a window of its own, to keep open.",
173
178
  restartHint: "Choose the model and effort, then press Claude Code or Codex: the chat starts again, clean, in the same window, once it has ended its answer. The game stays as it is.",
174
179
  byHand: (name) => `You opened this chat yourself: change ${name}'s model with /model in its window, or close it and open one from here, with the choices below.`,
175
180
  noChat: "No chat at the table: choose one",
@@ -298,7 +303,7 @@ const english = {
298
303
  start: "Analyse",
299
304
  yours: "Your analyses",
300
305
  bring: "Load a game",
301
- bringNote: "a PGN played elsewhere, pasted or from a file",
306
+ bringNote: "from Lichess or Chess.com, or a PGN pasted or from a file",
302
307
  search: "Names, models, openings…",
303
308
  noMatch: "No game found",
304
309
  reviewed: "reviewed",
@@ -319,6 +324,19 @@ const english = {
319
324
  askUnreview: (game) => ({ question: `Delete the analysis of ${game}?`, note: "Its badges, notes and the coach's judgments go; the game stays.", yes: "Delete" }),
320
325
  askRemove: (game, when) => ({ question: `Delete the game ${game}${when === undefined ? "" : ` of ${when}`}?`, note: "With its analysis. It cannot be undone.", yes: "Delete" }),
321
326
  cancel: "Cancel",
327
+ siteGames: "Your latest games on the sites",
328
+ siteGamesLoading: "Asking Lichess and Chess.com…",
329
+ siteGamesNone: "No finished game on the sites.",
330
+ siteGamesNoAccount: "Your names on Lichess and Chess.com bring your latest games here.",
331
+ siteGamesProfile: "Add them",
332
+ bothUnreached: "Lichess and Chess.com aren't answering: we'll try again later",
333
+ siteGamesRefused: {
334
+ "game-going": "A game is going on: finish it to bring another.",
335
+ "no-accounts": "Your profile names no account on Lichess or Chess.com.",
336
+ "no-network": "Chesspal cannot reach the sites from here.",
337
+ },
338
+ playedAs: { white: "You played White", black: "You played Black" },
339
+ speed: (name) => ({ ultraBullet: "ultrabullet", correspondence: "correspondence", daily: "daily" }[name] ?? readableSpeed(name)),
322
340
  download: "Annotated PGN",
323
341
  downloadHint: "The game with the review's comments and judgments",
324
342
  analyseIt: "Analyse the game",
@@ -494,7 +512,7 @@ const italian = {
494
512
  saveMoves: "PGN",
495
513
  saveMovesHint: "Solo le mosse, da aprire su Lichess o chess.com",
496
514
  noGameNow: "Nessuna partita in corso",
497
- analysing: "Analisi in corso",
515
+ analysing: "Analisi",
498
516
  badges: {
499
517
  brilliant: "Geniale",
500
518
  great: "Ottima mossa",
@@ -522,6 +540,9 @@ const italian = {
522
540
  mated: (side) => `Matto al ${side === "white" ? "Bianco" : "Nero"}`,
523
541
  },
524
542
  reviewing: (name) => `${name} sta analizzando la partita…`,
543
+ incomplete: "Analisi non completa",
544
+ complete: "Completa",
545
+ completeHint: "Completa l'analisi",
525
546
  you: "Tu",
526
547
  ai: "AI",
527
548
  sides: { white: "Bianco", black: "Nero" },
@@ -577,6 +598,8 @@ const italian = {
577
598
  table: {
578
599
  title: "Contro chi giochi?",
579
600
  choose: "Scegli la chat con cui giocare: parte in una finestra a parte, da tenere aperta.",
601
+ reviewTitle: "Chi analizza la partita?",
602
+ reviewChoose: "Scegli la chat che analizza la partita: parte in una finestra a parte, da tenere aperta.",
580
603
  restartHint: "Scegli modello e ragionamento, poi premi Claude Code o Codex: la chat riparte pulita nella stessa finestra, appena ha finito di rispondere. La partita resta com'è.",
581
604
  byHand: (name) => `Questa chat l'hai aperta tu: cambia il modello di ${name} con /model nella sua finestra, oppure chiudila e aprine una da qui, con le scelte qui sotto.`,
582
605
  noChat: "Nessuna chat al tavolo: scegline una",
@@ -706,7 +729,7 @@ const italian = {
706
729
  start: "Analizza",
707
730
  yours: "Le tue analisi",
708
731
  bring: "Carica una partita",
709
- bringNote: "un PGN giocato altrove, incollato o da file",
732
+ bringNote: "da Lichess o Chess.com, o un PGN incollato o da file",
710
733
  search: "Nomi, modelli, aperture…",
711
734
  noMatch: "Nessuna partita trovata",
712
735
  reviewed: "commentata",
@@ -727,6 +750,19 @@ const italian = {
727
750
  askUnreview: (game) => ({ question: `Eliminare l'analisi di ${game}?`, note: "Bollini, note e giudizi dell'allenatore si cancellano; la partita resta.", yes: "Elimina" }),
728
751
  askRemove: (game, when) => ({ question: `Eliminare la partita ${game}${when === undefined ? "" : ` del ${when}`}?`, note: "Con la sua analisi. Non si può annullare.", yes: "Elimina" }),
729
752
  cancel: "Annulla",
753
+ siteGames: "Le tue ultime partite sui siti",
754
+ siteGamesLoading: "Chiedo a Lichess e Chess.com…",
755
+ siteGamesNone: "Nessuna partita finita sui siti.",
756
+ siteGamesNoAccount: "I tuoi nomi su Lichess e Chess.com portano qui le tue ultime partite.",
757
+ siteGamesProfile: "Aggiungili",
758
+ bothUnreached: "Lichess e Chess.com non rispondono: riproviamo più tardi",
759
+ siteGamesRefused: {
760
+ "game-going": "C'è una partita in corso: finiscila per portarne un'altra.",
761
+ "no-accounts": "Il tuo profilo non ha nomi su Lichess o Chess.com.",
762
+ "no-network": "Da qui Chesspal non raggiunge i siti.",
763
+ },
764
+ playedAs: { white: "Hai giocato col Bianco", black: "Hai giocato col Nero" },
765
+ speed: (name) => ({ ultraBullet: "ultrabullet", classical: "classica", correspondence: "per corrispondenza", daily: "per corrispondenza" }[name] ?? readableSpeed(name)),
730
766
  download: "PGN annotato",
731
767
  downloadHint: "La partita con i commenti e i giudizi della revisione",
732
768
  analyseIt: "Analizza la partita",
@@ -911,3 +947,7 @@ function moveInWords(words, move) {
911
947
  function capital(words) {
912
948
  return words.charAt(0).toUpperCase() + words.slice(1);
913
949
  }
950
+ /** A speed the page does not know, as a word: ultraBullet as «ultra bullet». */
951
+ function readableSpeed(name) {
952
+ return name.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase();
953
+ }
@@ -6,6 +6,7 @@ import { get } from "node:http";
6
6
  import { join, resolve } from "node:path";
7
7
  import { writeAtomically } from "../../games/index.js";
8
8
  import { dataFolder, tableFolder } from "../../home/index.js";
9
+ import { analystAgent } from "../../host-files/index.js";
9
10
  import { version } from "../../version.js";
10
11
  /** What the chat hears from the doorbell: one line for whatever the player did, never what, only where to read it. */
11
12
  export const bellLine = "The player acted on the board page: call status to see it.";
@@ -171,7 +172,8 @@ export async function bellHeard(input, ports) {
171
172
  const pages = ours.length > 1 ? `${ours.length} pages of it, none of this chat's session, ${open} open in a browser` : "no page of it";
172
173
  return { folder, rang: false, why: `${pages} at ports ${tried.join(", ")}${others.length > 0 ? `; ${others.join(", ")}` : ""}` };
173
174
  }
174
- const said = await body(port, "/api/bell");
175
+ // The page lets the analyst go as the hook starts waiting, when none runs any more.
176
+ const said = await body(port, hook.analystGone === true ? "/api/bell?analyst=gone" : "/api/bell");
175
177
  return said === "ring" ? { folder, rang: true } : { folder, rang: false, why: said === undefined ? `the page at port ${port} did not answer` : `the page at port ${port} said "${said}"` };
176
178
  }
177
179
  // How long a port may take to say whose page it is: one of this computer answers at once.
@@ -244,12 +246,23 @@ function hookInput(input) {
244
246
  return {
245
247
  ...(typeof parsed.session_id === "string" ? { session: parsed.session_id } : {}),
246
248
  ...(typeof parsed.cwd === "string" ? { cwd: parsed.cwd } : {}),
249
+ ...(analystGone(parsed.background_tasks) ? { analystGone: true } : {}),
247
250
  };
248
251
  }
249
252
  catch {
250
253
  return {};
251
254
  }
252
255
  }
256
+ /**
257
+ * Whether Claude Code's Stop hook says no analyst runs in the background: after it ends, is stopped or fails, the chat takes a turn, and its Stop hook
258
+ * lists the tasks still running (D-109, Priya's test). A host that lists none says nothing.
259
+ */
260
+ function analystGone(tasks) {
261
+ return Array.isArray(tasks) && !tasks.some((task) => {
262
+ const { agent_type: agent, status } = (task ?? {});
263
+ return agent === analystAgent && status === "running";
264
+ });
265
+ }
253
266
  /**
254
267
  * The chat's page among those of its folder: the one of its session; else the only one, or the only one a browser has open, where the player plays. Codex
255
268
  * gives the page no session, and a chat closed may leave its page behind for a while.
@@ -191,10 +191,10 @@ function track(call) {
191
191
  void call.finally(() => running.delete(call));
192
192
  return call;
193
193
  }
194
- /** Every hint stated, since the spec reads a missing one as the worst case: everything is local, so no tool reaches the open world. */
194
+ /** Every hint stated, since the spec reads a missing one as the worst case: everything is local, but for the tools that ask the sites the player plays on (D-110). */
195
195
  function toolConfig(command) {
196
196
  const readOnly = command.readOnly === true;
197
- const annotations = { readOnlyHint: readOnly, destructiveHint: command.destructive === true, idempotentHint: readOnly || command.idempotent === true, openWorldHint: false };
197
+ const annotations = { readOnlyHint: readOnly, destructiveHint: command.destructive === true, idempotentHint: readOnly || command.idempotent === true, openWorldHint: command.openWorld === true };
198
198
  const meta = command.alwaysLoad ? { "anthropic/alwaysLoad": true } : undefined;
199
199
  // The SDK writes the draft's address in every schema: it says nothing to an AI, and costs tokens in every tool.
200
200
  return { description: command.description, inputSchema: z.looseObject({}).meta({ ...shownInput(command), $schema: undefined }), annotations, _meta: meta };
@@ -23,3 +23,8 @@ export interface AnalysisItem {
23
23
  export declare function analysesOf(home: string): AnalysisItem[];
24
24
  /** 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. */
25
25
  export declare function answerAnalyses(request: IncomingMessage, response: ServerResponse, url: URL, served: Served): boolean;
26
+ /**
27
+ * Sends the player's latest games on the sites, as site_games gives them with their PGNs (D-110); its refusal as a 409, with the code the page says it by in
28
+ * its own words: a game going on, no name on the sites, no network.
29
+ */
30
+ export declare function sendSiteGames(response: ServerResponse, served: Served): void;
@@ -1,4 +1,4 @@
1
- import { bookOpening, deleteGame, deleteReview } from "../../commands/index.js";
1
+ import { bookOpening, deleteGame, deleteReview, siteGames, SiteGamesRefused } from "../../commands/index.js";
2
2
  import { aiModelIn, annotatorOf, isReviewed, leadOf, reviewOf } from "../../core/index.js";
3
3
  import { archivedGame, archivedGames, gameInAnalysis, gameStemOf, keptGame, keptGames } from "../../games/index.js";
4
4
  import { gameFolder } from "../../profiles/index.js";
@@ -33,6 +33,23 @@ export function answerAnalyses(request, response, url, served) {
33
33
  });
34
34
  return true;
35
35
  }
36
+ /**
37
+ * Sends the player's latest games on the sites, as site_games gives them with their PGNs (D-110); its refusal as a 409, with the code the page says it by in
38
+ * its own words: a game going on, no name on the sites, no network.
39
+ */
40
+ export function sendSiteGames(response, served) {
41
+ runRoute(response, async () => {
42
+ try {
43
+ sendJson(response, 200, await siteGames.run({}, served.context));
44
+ }
45
+ catch (error) {
46
+ if (!(error instanceof SiteGamesRefused)) {
47
+ throw error;
48
+ }
49
+ sendJson(response, 409, { error: error.message, refusal: error.refusal });
50
+ }
51
+ });
52
+ }
36
53
  const deletions = { "/api/delete-game": deleteGame, "/api/delete-review": deleteReview };
37
54
  function itemOf(name, read, taken) {
38
55
  let record;
@@ -1,7 +1,7 @@
1
1
  // The table's end of the chat attached to it (D-104): the chat attaches and stays while its process lives, says when it calls a tool, sends a copy of what
2
2
  // it saw, and asks for the New game window. One chat at a time, of this version alone; a page a chat serves itself has none of these routes.
3
3
  import { randomUUID } from "node:crypto";
4
- import { forgetTheAi, noteAiHost, noteAiModel } from "../../games/index.js";
4
+ import { forgetAnalyst, forgetTheAi, noteAiHost, noteAiModel } from "../../games/index.js";
5
5
  import { gameFolder } from "../../profiles/index.js";
6
6
  import { version } from "../../version.js";
7
7
  import { chatAttach, chatCopy } from "./chat.js";
@@ -67,10 +67,15 @@ function attach(body, response, served) {
67
67
  });
68
68
  tellWatchers(served.watchers);
69
69
  }
70
- /** The next chat may play in another host, with another model: the AI's card shows its own from its first move (Federico). */
70
+ /**
71
+ * The next chat may play in another host, with another model: the AI's card shows its own from its first move (Federico). The analyst ended with its chat
72
+ * (D-109): the game it held is free for the next one.
73
+ */
71
74
  function forgetChat(served) {
72
75
  try {
73
- forgetTheAi(gameFolder(served.context.home()));
76
+ const folder = gameFolder(served.context.home());
77
+ forgetTheAi(folder);
78
+ forgetAnalyst(folder);
74
79
  }
75
80
  catch {
76
81
  // A data folder that cannot be read keeps no notes to forget.
@@ -245,7 +245,7 @@ async function studyFromPage(request, response, served) {
245
245
  let brought;
246
246
  try {
247
247
  // Without what the chat was told: the analyst's instructions stay for the AI's own analyse_game.
248
- const opened = last ? lastPlayed(served.context) : game === undefined ? kept(served, pgn) : namedGame(served.context, game);
248
+ const opened = last ? lastPlayed(served.context) : game === undefined ? await kept(served, pgn) : namedGame(served.context, game);
249
249
  reviewed = isReviewed(opened.record);
250
250
  brought = !reviewed && !look;
251
251
  // A game not yet reviewed is marked as brought, for status to ask the AI to go over it; one reviewed is ready, and the chat sleeps on.
@@ -356,9 +356,12 @@ function analysisShown(served) {
356
356
  // started again since is gone (Priya). On the table the chat's start is in the chat's own folder.
357
357
  const { bell } = served;
358
358
  const chat = { ...served.context, folder: served.attached?.chat.folder ?? served.folder };
359
- const reviewing = !isReviewed(record) && (bell.inTurn || (bell.listening && analystOf(chat, analysis.game) !== undefined));
359
+ const reviewed = isReviewed(record);
360
+ const reviewing = !reviewed && (bell.inTurn || (bell.listening && analystOf(chat, analysis.game) !== undefined));
361
+ // Without a review and with nobody at it, the analysis is incomplete: the page offers to bring it back to the AI (D-109).
362
+ const state = reviewed ? {} : reviewing ? { reviewing: true } : { incomplete: true };
360
363
  // How far the book goes, which the page cannot tell without the book's moves: its card names the opening on those moves.
361
- return { name, pgn: writePgn(record), book: bookPlies(record), ...(reviewing ? { reviewing: true } : {}) };
364
+ return { name, pgn: writePgn(record), book: bookPlies(record), ...state };
362
365
  }
363
366
  catch {
364
367
  // A game gone from the archive is no longer shown.
@@ -374,8 +377,8 @@ function lastPlayed(context) {
374
377
  return { name, record };
375
378
  }
376
379
  /** A game brought as a PGN, kept to study as analyse_game keeps it: the one already kept when it is the same game, with its review. */
377
- function kept(served, pgn) {
378
- analyseGame.run({ pgn }, served.context);
380
+ async function kept(served, pgn) {
381
+ await analyseGame.run({ pgn }, served.context);
379
382
  return namedGame(served.context, gameInAnalysis(gameFolder(served.context.home())).game);
380
383
  }
381
384
  /** The longest PGN the page brings: a long game with its clocks, and the comments a site adds, which go. */
@@ -5,11 +5,11 @@ import { createServer } from "node:http";
5
5
  import { join } from "node:path";
6
6
  import { positionIn, samePosition } from "../../commands/index.js";
7
7
  import { aiNameOf } from "../../core/index.js";
8
- import { gameStamp, tableTalkStamp } from "../../games/index.js";
8
+ import { forgetAnalyst, gameStamp, tableTalkStamp } from "../../games/index.js";
9
9
  import { outdatedHostFiles } from "../../host-files/index.js";
10
10
  import { gameFolder } from "../../profiles/index.js";
11
11
  import { Bell, bellProbe, pagePorts } from "../bell/index.js";
12
- import { analysesOf, answerAnalyses } from "./analyses.js";
12
+ import { analysesOf, answerAnalyses, sendSiteGames } from "./analyses.js";
13
13
  import { answerChat } from "./attach.js";
14
14
  import { attachedSeen, chatSeenOf, pageContext } from "./chat.js";
15
15
  import { askNewGame, keepPosted, tellWatchers } from "./events.js";
@@ -131,6 +131,9 @@ function answer(request, response, served) {
131
131
  else if (path === "/api/analyses") {
132
132
  send(response, 200, json, JSON.stringify(analysesOf(served.context.home())));
133
133
  }
134
+ else if (path === "/api/site-games") {
135
+ sendSiteGames(response, served);
136
+ }
134
137
  else if (path === "/api/events") {
135
138
  keepPosted(request, response, served.watchers);
136
139
  // A page that opens reads the player's ratings again after a day (D-106).
@@ -141,6 +144,9 @@ function answer(request, response, served) {
141
144
  send(response, 200, json, JSON.stringify(bellProbe(served.folder, served.watchers.size > 0, tableChat(served))));
142
145
  }
143
146
  else {
147
+ if (url.searchParams.get("analyst") === "gone") {
148
+ forgetGoneAnalyst(served);
149
+ }
144
150
  served.bell.listen(request, response);
145
151
  }
146
152
  }
@@ -148,6 +154,15 @@ function answer(request, response, served) {
148
154
  sendFile(response, path);
149
155
  }
150
156
  }
157
+ /** The chat's hook says no analyst runs any more (D-109): the game it held is free, and its review, unless finished, incomplete. */
158
+ function forgetGoneAnalyst(served) {
159
+ try {
160
+ forgetAnalyst(gameFolder(served.context.home()));
161
+ }
162
+ catch {
163
+ // A data folder that cannot be written keeps the analyst until half an hour has gone by.
164
+ }
165
+ }
151
166
  /** The chat at the table, as its probe says it: none on a page a chat serves itself. */
152
167
  function tableChat(served) {
153
168
  if (served.table === undefined) {
@@ -1,4 +1,4 @@
1
1
  import type { SiteReading } from "../profiles/index.ts";
2
- import { type Fetch } from "./request.ts";
2
+ import { type Fetch } from "./request/index.ts";
3
3
  /** What Chess.com says of a name: undefined when it could not be reached. */
4
4
  export declare function chesscomReading(account: string, fetch: Fetch, signal: AbortSignal): Promise<SiteReading | undefined>;
@@ -1,5 +1,5 @@
1
1
  import { counted } from "./choice.js";
2
- import { askSite } from "./request.js";
2
+ import { askSite } from "./request/index.js";
3
3
  /** What Chess.com says of a name: undefined when it could not be reached. */
4
4
  export async function chesscomReading(account, fetch, signal) {
5
5
  const statistics = await askSite(`https://api.chess.com/pub/player/${encodeURIComponent(account.toLowerCase())}/stats`, fetch, signal);
@@ -0,0 +1,4 @@
1
+ import { type Fetch } from "../request/index.ts";
2
+ import { type SiteGamesAnswer } from "./game.ts";
3
+ /** What Chess.com gives of a name's latest games, the latest first. */
4
+ export declare function chesscomGames(account: string, fetch: Fetch, signal: AbortSignal): Promise<SiteGamesAnswer>;