chesspal 0.3.2 → 0.3.3

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.
@@ -38,7 +38,7 @@ export declare function agentsLine(agents: readonly string[]): string;
38
38
  export declare function goneOver(context: Context, game: string): boolean;
39
39
  /**
40
40
  * The run of the analyst agent at work on a game: the one that took it last, unless its chat has started again since, not only summarized, and took the
41
- * agent with it.
41
+ * agent with it, or it has written nothing for half an hour, as one long dead (D-109): a quiet minute proves nothing, since an analyst may think long.
42
42
  */
43
43
  export declare function analystOf(context: Context, game: string): string | undefined;
44
44
  /** What stops an analyst agent on a game another analyst has taken: the chat hears of the review from that one (Priya). */
@@ -90,14 +90,18 @@ export function goneOver(context, game) {
90
90
  }
91
91
  /**
92
92
  * The run of the analyst agent at work on a game: the one that took it last, unless its chat has started again since, not only summarized, and took the
93
- * agent with it.
93
+ * agent with it, or it has written nothing for half an hour, as one long dead (D-109): a quiet minute proves nothing, since an analyst may think long.
94
94
  */
95
95
  export function analystOf(context, game) {
96
- const taken = gameInAnalysis(gameFolder(context.home()))?.analyst;
96
+ const analysis = gameInAnalysis(gameFolder(context.home()));
97
+ const taken = analysis?.analyst;
97
98
  const start = lastChatStart(context.folder);
98
- const gone = taken !== undefined && start !== undefined && start.source !== "compact" && Date.parse(start.at) > Date.parse(taken.since);
99
- return taken?.game === game && !gone ? taken.run : undefined;
99
+ const restarted = taken !== undefined && start !== undefined && start.source !== "compact" && Date.parse(start.at) > Date.parse(taken.since);
100
+ const quiet = analysis !== undefined && context.now().getTime() - Date.parse(analysis.at) > analystQuiet;
101
+ return taken?.game === game && !restarted && !quiet ? taken.run : undefined;
100
102
  }
103
+ /** How long an analyst may write nothing before it counts as gone. */
104
+ const analystQuiet = 30 * 60 * 1000;
101
105
  /** What stops an analyst agent on a game another analyst has taken: the chat hears of the review from that one (Priya). */
102
106
  export const anotherAnalyst = "Stop now: another analyst is reviewing this game. End with one line for the AI that started you: its review is on its way to the "
103
107
  + "board page, and its end comes to you by itself; meanwhile talk the game over with the player whenever they ask. Should it never come, the player has "
@@ -43,7 +43,7 @@ import { textFor } from "../text.js";
43
43
  import { anchorName, useAnchor } from "./anchor.js";
44
44
  import { useBrowseKeys, useFoldKeys, useLegendKey, useMapKey, useWheelSteps } from "./keys.js";
45
45
  import { phaseOf, verdictOf } from "./phase.js";
46
- import { post, sendMessage, sendMove, useServed } from "./served.js";
46
+ import { ask, post, sendMessage, sendMove, useServed } from "./served.js";
47
47
  import { tabTitle, useTabTitle } from "./title.js";
48
48
  /**
49
49
  * The page: the board of the game in the player's folder, where the player moves on their turn, the talk with the AI beside it, and the game's column; in the
@@ -71,12 +71,23 @@ export function App({ start }) {
71
71
  const { data: served, problem, offline, reload } = useServed(() => setDialog("new-game"));
72
72
  // Nuova partita with no chat at the table asks for one first (D-107): the chat's window, then the new game's, however the first closes.
73
73
  const [chatFirst, setChatFirst] = useState(false);
74
- const askNewGame = () => {
74
+ // At the table with no chat, and none opening: what asks for the AI opens the chat's window first, to choose its host and model (D-107, D-109).
75
+ const chatMissing = () => {
75
76
  const table = offline ? undefined : served?.table;
76
- const first = table !== undefined && !table.chat && table.opening === undefined;
77
+ return table !== undefined && !table.chat && table.opening === undefined;
78
+ };
79
+ // The chat's window opened for an analysis asks who reviews the game, not whom the player plays (Rosa, Zoe).
80
+ const [chatToReview, setChatToReview] = useState(false);
81
+ const askNewGame = () => {
82
+ const first = chatMissing();
77
83
  setChatFirst(first);
84
+ setChatToReview(false);
78
85
  setDialog(first ? "chat" : "new-game");
79
86
  };
87
+ const askChat = () => {
88
+ setChatToReview(false);
89
+ setDialog("chat");
90
+ };
80
91
  const closeChat = () => {
81
92
  setDialog(chatFirst ? "new-game" : undefined);
82
93
  setChatFirst(false);
@@ -302,6 +313,8 @@ export function App({ start }) {
302
313
  const summary = studied === undefined ? undefined : gameReviewOf(live.record);
303
314
  // An AI is going over the game only when the server says so: with no chat at it, nobody is (Stefano).
304
315
  const reviewing = summary === undefined && studied?.reviewing === true && !offline;
316
+ // With nobody at it, a review not finished says so, and the player brings the game back to the AI (D-109).
317
+ const completeIt = summary === undefined && studied?.incomplete === true && !offline ? () => void studyGame({ game: studied.name }) : undefined;
305
318
  const closing = studied === undefined ? undefined : closingOf(live.record);
306
319
  // In analysis the AI's card says who played that game, as its PGN keeps it (D-82), not who plays now.
307
320
  const playedBy = studied === undefined ? undefined : aiModelIn(live.record);
@@ -320,7 +333,7 @@ export function App({ start }) {
320
333
  // Where to wake the chat, under the AI's card (over the board when the talk is folded); the button that starts the clocks, under the player's (Zoe).
321
334
  // On the table, what its chat is doing stands there, and in a game with no chat at it a line that opens the chat's window (D-107).
322
335
  const table = offline ? undefined : served.table;
323
- const atTable = table === undefined ? null : tableNote({ table, name, inGame: going, onOpen: () => setDialog("chat"), text });
336
+ const atTable = table === undefined ? null : tableNote({ table, name, inGame: going, onOpen: askChat, text });
324
337
  const asleepNote = atTable !== null
325
338
  ? atTable
326
339
  : asleep && table?.chat !== false
@@ -402,15 +415,19 @@ export function App({ start }) {
402
415
  };
403
416
  // In analysis the AI's note goes with the other side's card and the talk with the player's, turned round with the board as in a game (D-101, Zoe).
404
417
  const studiedCard = (side, place) => sides === undefined ? null : (_jsx(PlayerCard, { place: place, picture: pictureOf(sides[side], profile, 48, mood), name: sides[side].name, signed: sides[side].who === "ai" && playedBy !== undefined ? signature(playedBy.model, playedBy.effort) : undefined, side: side, taken: material.taken[side], ahead: ahead(side), badges: _jsx(BadgeCounts, { plies: plies, side: side, names: text.badges }), text: text }));
405
- // A game brought to analyse: the page shows it once the server has it in analysis; a refusal stays in the dialog.
418
+ // A game brought to analyse: the page shows it once the server has it in analysis; a refusal stays in the dialog. One the AI has to go over, with no
419
+ // chat at the table, opens the chat's window: the analyst works with the chat's model (D-109).
406
420
  const studyGame = async (studied) => {
407
- const refused = await post("/api/study", studied, text, name);
408
- if (refused === undefined) {
409
- setDialog(undefined);
421
+ const sent = await ask("/api/study", studied, text, name);
422
+ if ("refused" in sent) {
423
+ return sent.refused.text;
410
424
  }
411
- return refused?.text;
425
+ const toReview = sent.answer.reviewed === false && chatMissing();
426
+ setChatToReview(toReview);
427
+ setDialog(toReview ? "chat" : undefined);
428
+ return undefined;
412
429
  };
413
- return (_jsx(ChosenSet.Provider, { value: settings.set, children: _jsxs(PointSquare.Provider, { value: pointSquare, children: [panel, _jsx(ProfileDialog, { open: dialog === "profile", profile: profile, onClose: () => setDialog(undefined), text: text }), _jsx(ProfilesDialog, { open: dialog === "profiles", profiles: served.profiles, active: profile, locked: going ? text.profile.whilePlaying : undefined, dataFolder: served.dataFolder, onEdit: () => setDialog("profile"), onClose: () => setDialog(undefined), text: text }), table === undefined ? null : _jsx(ChatDialog, { open: dialog === "chat", table: table, settings: settings, onChange: change, name: name, onClose: closeChat, text: text }), _jsx(NewGameDialog, { open: dialog === "new-game", choice: choice, going: going, onStart: startNewGame, onClose: () => setDialog(undefined), text: text }), guide, _jsx(StudyDialog, { open: dialog === "study", onStudy: studyGame, onClose: () => setDialog(undefined), you: profile.name, accounts: profile.accounts, ai: name, text: text }), served.update === undefined || served.table === undefined
430
+ return (_jsx(ChosenSet.Provider, { value: settings.set, children: _jsxs(PointSquare.Provider, { value: pointSquare, children: [panel, _jsx(ProfileDialog, { open: dialog === "profile", profile: profile, onClose: () => setDialog(undefined), text: text }), _jsx(ProfilesDialog, { open: dialog === "profiles", profiles: served.profiles, active: profile, locked: going ? text.profile.whilePlaying : undefined, dataFolder: served.dataFolder, onEdit: () => setDialog("profile"), onClose: () => setDialog(undefined), text: text }), table === undefined ? null : _jsx(ChatDialog, { open: dialog === "chat", review: chatToReview, table: table, settings: settings, onChange: change, name: name, onClose: closeChat, text: text }), _jsx(NewGameDialog, { open: dialog === "new-game", choice: choice, going: going, onStart: startNewGame, onClose: () => setDialog(undefined), text: text }), guide, _jsx(StudyDialog, { open: dialog === "study", onStudy: studyGame, onClose: () => setDialog(undefined), you: profile.name, accounts: profile.accounts, ai: name, text: text }), served.update === undefined || served.table === undefined
414
431
  ? null
415
432
  : (_jsx(UpdateDialog, { open: dialog === "update", version: served.update.version, current: served.version, installing: served.update.installing === true, installed: served.update.installed === true, going: going, chat: served.table.chat ? (served.table.held ? "held" : "byHand") : undefined, name: name, onClose: () => setDialog(undefined), text: text })), offline ? _jsx("p", { class: "offline", role: "alert", children: text.offline(name) }) : null, served.outdated === true
416
433
  ? _jsx("p", { class: "outdated", role: "status", children: text.outdated(name) })
@@ -419,11 +436,11 @@ export function App({ start }) {
419
436
  : null, _jsxs("main", { class: `app${flipped ? " flipped" : ""}${folds.talk === "open" ? "" : ` talk-${folds.talk}`}${folds.game === "folded" ? " folded" : ""}`, inert: offline, children: [header, sides === undefined
420
437
  ? (_jsx(TalkFaces, { name: name, aiPicture: thinker(40), you: profile.name, picture: picture, ...(idle ? {} : { ai, player }), aiMoves: going && !playerToMove, playerMoves: playerToMove, unread: unread || asleep, text: text }))
421
438
  : (_jsx(TalkFaces, { name: sides[ai].name, aiPicture: pictureOf(sides[ai], profile, 40, mood), you: sides[player].name, picture: pictureOf(sides[player], profile, 40, mood), ai: ai, player: player, aiMoves: false, playerMoves: false, unread: asleep, text: text })), _jsx(FoldTab, { column: "talk", folded: folds.talk === "folded", label: thinking === undefined || folds.talk !== "folded" ? text.talk : `${text.talk}: ${text.thinking[thinking]}`, unread: (unread || asleep) && folds.talk === "folded", ...(thinking === undefined || folds.talk !== "folded" ? {} : { mark: _jsx(Thought, { thinking: thinking, words: text.thinking }) }), onFold: fold }), _jsx(FoldTab, { column: "game", folded: folds.game === "folded", label: text.game, onFold: fold }), _jsx("span", { class: "visually-hidden", "aria-live": "polite", children: unread ? text.wrote(name) : "" }), _jsx("div", { class: sides === undefined ? "talk" : "talk studied", id: "talk", children: sides !== undefined && studied !== undefined
422
- ? (_jsxs(_Fragment, { children: [_jsxs("section", { class: "ai-block", "aria-label": sides[ai].name, children: [studiedCard(ai, "ai"), asleepNote, _jsx(AiNote, { ...(reviewed === undefined ? {} : { ply: reviewed }), ...(shownOpening === undefined ? {} : { opening: shownOpening }), ...(at === total && closing !== undefined ? { closing } : {}), ...(at === 0 && summary !== undefined ? { summary } : {}), ...(reviewing ? { reviewing: true } : {}), name: analyst, by: annotator, mood: mood, ...(settings.voice ? { voice: text } : {}), text: text })] }), _jsxs("section", { class: "you-block", "aria-label": sides[player].name, children: [_jsx("div", { class: "chat", children: _jsx(Compose, { lines: analysisTalk(talk, studied.name), newestFirst: false, spent: 0, draft: draft, onDraft: (words) => {
439
+ ? (_jsxs(_Fragment, { children: [_jsxs("section", { class: "ai-block", "aria-label": sides[ai].name, children: [studiedCard(ai, "ai"), asleepNote, _jsx(AiNote, { ...(reviewed === undefined ? {} : { ply: reviewed }), ...(shownOpening === undefined ? {} : { opening: shownOpening }), ...(at === total && closing !== undefined ? { closing } : {}), ...(at === 0 && summary !== undefined ? { summary } : {}), ...(reviewing ? { reviewing: true } : {}), ...(completeIt === undefined ? {} : { onComplete: completeIt }), name: analyst, by: annotator, mood: mood, ...(settings.voice ? { voice: text } : {}), text: text })] }), _jsxs("section", { class: "you-block", "aria-label": sides[player].name, children: [_jsx("div", { class: "chat", children: _jsx(Compose, { lines: analysisTalk(talk, studied.name), newestFirst: false, spent: 0, draft: draft, onDraft: (words) => {
423
440
  setDraft(words);
424
441
  setUnsent(undefined);
425
442
  }, onSend: () => sendWords({ game: studied.name, at }), canMove: false, name: name, problem: unsent, about: { ...(at === 0 ? {} : { ply: plies[at - 1] }), onShow: goTo }, text: text }) }), studiedCard(player, "you")] })] }))
426
- : (_jsxs(_Fragment, { children: [_jsxs("section", { class: "ai-block", "aria-label": name, children: [_jsx(PlayerCard, { place: "ai", picture: thinker(48), name: table === undefined ? name : _jsx(AiNameButton, { name: name, table: table, onOpen: () => setDialog("chat"), text: text }), signed: signed, side: idle ? undefined : ai, ...(idle ? {} : { level: text.start.levels[level] }), taken: material.taken[ai], ahead: ahead(ai), clock: clockOf(ai), text: text }), asleepNote, _jsx(AiWords, { words: aiWords(plies, ai, talk), newestFirst: !flipped, ...(settings.voice ? { voice: text } : {}) })] }), _jsxs("section", { class: "you-block", "aria-label": profile.name, children: [_jsx(Compose, { lines: said, newestFirst: flipped,
443
+ : (_jsxs(_Fragment, { children: [_jsxs("section", { class: "ai-block", "aria-label": name, children: [_jsx(PlayerCard, { place: "ai", picture: thinker(48), name: table === undefined ? name : _jsx(AiNameButton, { name: name, table: table, onOpen: askChat, text: text }), signed: signed, side: idle ? undefined : ai, ...(idle ? {} : { level: text.start.levels[level] }), taken: material.taken[ai], ahead: ahead(ai), clock: clockOf(ai), text: text }), asleepNote, _jsx(AiWords, { words: aiWords(plies, ai, talk), newestFirst: !flipped, ...(settings.voice ? { voice: text } : {}) })] }), _jsxs("section", { class: "you-block", "aria-label": profile.name, children: [_jsx(Compose, { lines: said, newestFirst: flipped,
427
444
  // Between games, and before the first, the player writes freely, a line at a time, as the AI does (D-74, D-77).
428
445
  spent: going ? playerSpent(plies, ai, talk) : 0, draft: draft, onDraft: (words) => {
429
446
  setDraft(words);
@@ -434,7 +451,14 @@ export function App({ start }) {
434
451
  // A press on the board while the player looks back brings the game as it is: the next one plays.
435
452
  onPointerDownCapture: viewing === undefined || studied !== undefined ? undefined : (event) => event.button === 0 && goTo(total), children: [_jsx(Board, { fen: shown.position.fen, bottom: flipped ? ai : player, lastMove: shown.lastMove, pointed: pointed, ...(helps === undefined ? {} : { helps }), ...(mapping && helps !== undefined ? { control: helps.strategy.control } : {}), bare: bare && helps !== undefined, ...(review?.badge === undefined ? {} : { badge: { badge: review.badge, name: text.badges[review.badge] } }), ...(drawn === undefined ? {} : { drawn }), nextMove: next, jumped: jumped, check: shown.position.check, end: boardEnd, player: playerToMove && pending === undefined && viewing === undefined ? player : undefined, selected: selected, targets: selected === undefined || viewing !== undefined ? [] : live.legalMoves(selected), dots: settings.dots, names: settings.squareNames, notice: viewing === undefined ? refusal : undefined, promotion: promoting === undefined || viewing !== undefined || !playerToMove ? undefined : { ...promoting, side: player }, onPromote: (piece) => piece === undefined ? setPromoting(undefined) : play(promoting.from, promoting.to, piece), onSelect: select, onMove: play, onRefuse: refuse, text: text }), folds.talk !== "open" && (asleepNote !== null || clockWaits)
436
453
  ? (_jsxs("div", { class: "over-board", children: [asleepNote, startClocks] }))
437
- : null] }), studied === undefined ? null : (_jsx("div", { class: "board-steps", children: _jsx(Steps, { shown: at, total: total, onStep: step, text: text }) })), helps === undefined ? null : (_jsxs("div", { class: "board-map", children: [legendAt(true), _jsx(BareButton, { bare: bare, onBare: setBare, text: text }), _jsx(MapButton, { mapping: mapping, onMap: setMapping, text: text }), hint === undefined || viewing !== undefined ? null : _jsx(HintLine, { hint: hint, open: hintOpen, onOpen: openTheHint, text: text })] })), _jsx(GameColumn, { plies: idle ? [] : plies, result: ended ? result : undefined, how: how, lit: justEnded, started: idle ? undefined : played?.started, move: live.position.moveNumber, toMove: live.position.turn, opening: opening, helps: helps === undefined ? undefined : (_jsx(HelpsCard, { helps: helps, judged: judgments, hint: viewing === undefined ? hint : undefined, hintOpen: hintOpen, onHint: openTheHint, onPoint: setPointed, onMap: setMapping, mapping: mapping, onBare: setBare, bare: bare, legend: legend, text: text })), ...(helps === undefined && legendRows.length > 0 ? { legend } : {}), ...(standings === undefined ? {} : { standings }), ...(studied === undefined ? {} : { analysis: { by: analyst, ...(summary === undefined ? {} : { summary }), ...(reviewing ? { reviewing: true } : {}) } }), shownPlies: at, onShow: goTo, onStep: step, ...(studied === undefined ? {} : { onDownload: () => download(studied.pgn, pgnName(live.record.tags, "analysis")) }), ...(total === 0 ? {} : { onSaveMoves: () => download(writePgn(movesOnly(live.record)), pgnName(live.record.tags)) }), ...(between && game !== null && replayed.record.moves.length > 0 ? { onAnalyse: () => void studyGame({ last: true }) } : {}), text: text }), _jsx(PageFoot, { version: served.version, text: text })] })] }) }));
454
+ : null] }), studied === undefined ? null : (_jsx("div", { class: "board-steps", children: _jsx(Steps, { shown: at, total: total, onStep: step, text: text }) })), helps === undefined ? null : (_jsxs("div", { class: "board-map", children: [legendAt(true), _jsx(BareButton, { bare: bare, onBare: setBare, text: text }), _jsx(MapButton, { mapping: mapping, onMap: setMapping, text: text }), hint === undefined || viewing !== undefined ? null : _jsx(HintLine, { hint: hint, open: hintOpen, onOpen: openTheHint, text: text })] })), _jsx(GameColumn, { plies: idle ? [] : plies, result: ended ? result : undefined, how: how, lit: justEnded, started: idle ? undefined : played?.started, move: live.position.moveNumber, toMove: live.position.turn, opening: opening, helps: helps === undefined ? undefined : (_jsx(HelpsCard, { helps: helps, judged: judgments, hint: viewing === undefined ? hint : undefined, hintOpen: hintOpen, onHint: openTheHint, onPoint: setPointed, onMap: setMapping, mapping: mapping, onBare: setBare, bare: bare, legend: legend, text: text })), ...(helps === undefined && legendRows.length > 0 ? { legend } : {}), ...(standings === undefined ? {} : { standings }), ...(studied === undefined ? {} : {
455
+ analysis: {
456
+ by: analyst,
457
+ ...(summary === undefined ? {} : { summary }),
458
+ ...(reviewing ? { reviewing: true } : {}),
459
+ ...(completeIt === undefined ? {} : { onComplete: completeIt }),
460
+ },
461
+ }), shownPlies: at, onShow: goTo, onStep: step, ...(studied === undefined ? {} : { onDownload: () => download(studied.pgn, pgnName(live.record.tags, "analysis")) }), ...(total === 0 ? {} : { onSaveMoves: () => download(writePgn(movesOnly(live.record)), pgnName(live.record.tags)) }), ...(between && game !== null && replayed.record.moves.length > 0 ? { onAnalyse: () => void studyGame({ last: true }) } : {}), text: text }), _jsx(PageFoot, { version: served.version, text: text })] })] }) }));
438
462
  }
439
463
  /** The picture of a player of the game in analysis: the player's own, the AI's Torretta with its mood, or the plain face of someone else. */
440
464
  function pictureOf({ who }, profile, size, mood) {
@@ -56,17 +56,22 @@ export function useServed(onNewGame) {
56
56
  * it sends the player, or undefined.
57
57
  */
58
58
  export async function post(path, body, text, name) {
59
+ const sent = await ask(path, body, text, name);
60
+ return "refused" in sent ? sent.refused : undefined;
61
+ }
62
+ /** Sends a request to the server as `post` does: its answer, or the reason it was refused. */
63
+ export async function ask(path, body, text, name) {
59
64
  try {
60
65
  const response = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
61
66
  if (response.ok) {
62
- return undefined;
67
+ return { answer: await response.json().catch(() => ({})) };
63
68
  }
64
69
  const refused = (await response.json());
65
70
  const known = refused.refusal === undefined ? undefined : text.refused[refused.refusal];
66
- return { text: known?.(refused.most ?? 0, name) ?? refused.error ?? response.statusText, words: refused.refusal?.startsWith("words-") === true };
71
+ return { refused: { text: known?.(refused.most ?? 0, name) ?? refused.error ?? response.statusText, words: refused.refusal?.startsWith("words-") === true } };
67
72
  }
68
73
  catch (error) {
69
- return { text: String(error), words: false };
74
+ return { refused: { text: String(error), words: false } };
70
75
  }
71
76
  }
72
77
  /** Sends the player's move to the server, which plays it as the chat would, with the words written for the AI: the reason it was refused, or undefined. */
@@ -14,7 +14,7 @@ export function GameColumn({ plies, result, how, lit, started, move, toMove, ope
14
14
  const bar = standings === undefined ? undefined : _jsx(LeadBar, { standing: standingAt(standings, shownPlies), shown: shownPlies, text: text });
15
15
  return (_jsx("aside", { class: "side", id: "game", "aria-label": text.game, children: started === undefined && analysis === undefined
16
16
  ? (_jsxs(_Fragment, { children: [_jsx("p", { class: "card empty", children: text.noGame }), onAnalyse === undefined ? null : _jsx(AnalyseButton, { label: text.study.analyseLast, onAnalyse: onAnalyse })] }))
17
- : (_jsxs(_Fragment, { children: [_jsxs("div", { class: "side-cards", children: [_jsx(GameCard, { plies: plies, result: result, how: how, lit: lit, started: started, move: move, toMove: toMove, bar: bar, text: text }), analysis === undefined || standings === undefined ? null : _jsx(LeadChart, { plies: plies, standings: standings, shown: shownPlies, onShow: onShow, text: text }), opening === undefined ? null : _jsx(OpeningCard, { opening: opening, text: text }), helps, analysis?.reviewing !== true ? null : (_jsxs("p", { class: "card review waiting", role: "status", children: [_jsx("span", { class: "spinner", "aria-hidden": "true" }), text.reviewing(analysis.by)] }))] }), _jsxs("div", { class: "side-foot", children: [result !== undefined && plies.length === 0 ? null : _jsx(MoveList, { plies: plies, result: result, shown: shownPlies, onShow: onShow, tools: legend, text: text }), onDownload === undefined && onSaveMoves === undefined ? null : (_jsxs("div", { class: "card analysis-actions", children: [onDownload === undefined ? null : _jsx(SaveButton, { label: text.study.download, hint: text.study.downloadHint, onSave: onDownload }), onSaveMoves === undefined ? null : _jsx(SaveButton, { label: text.saveMoves, hint: text.saveMovesHint, onSave: onSaveMoves })] })), onAnalyse === undefined ? null : _jsx(AnalyseButton, { label: text.study.analyseIt, onAnalyse: onAnalyse }), _jsx(Steps, { shown: shownPlies, total: plies.length, onStep: onStep, text: text })] })] })) }));
17
+ : (_jsxs(_Fragment, { children: [_jsxs("div", { class: "side-cards", children: [_jsx(GameCard, { plies: plies, result: result, how: how, lit: lit, started: started, move: move, toMove: toMove, bar: bar, text: text }), analysis?.reviewing !== true ? null : (_jsxs("p", { class: "card review waiting", role: "status", children: [_jsx("span", { class: "spinner", "aria-hidden": "true" }), text.reviewing(analysis.by)] })), analysis?.onComplete === undefined ? null : (_jsxs("div", { class: "card review incomplete", children: [_jsx("p", { role: "status", children: text.incomplete }), _jsx("button", { type: "button", title: text.completeHint, "aria-label": text.completeHint, onClick: analysis.onComplete, children: text.complete })] })), analysis === undefined || standings === undefined ? null : _jsx(LeadChart, { plies: plies, standings: standings, shown: shownPlies, onShow: onShow, text: text }), opening === undefined ? null : _jsx(OpeningCard, { opening: opening, text: text }), helps] }), _jsxs("div", { class: "side-foot", children: [result !== undefined && plies.length === 0 ? null : _jsx(MoveList, { plies: plies, result: result, shown: shownPlies, onShow: onShow, tools: legend, text: text }), onDownload === undefined && onSaveMoves === undefined ? null : (_jsxs("div", { class: "card analysis-actions", children: [onDownload === undefined ? null : _jsx(SaveButton, { label: text.study.download, hint: text.study.downloadHint, onSave: onDownload }), onSaveMoves === undefined ? null : _jsx(SaveButton, { label: text.saveMoves, hint: text.saveMovesHint, onSave: onSaveMoves })] })), onAnalyse === undefined ? null : _jsx(AnalyseButton, { label: text.study.analyseIt, onAnalyse: onAnalyse }), _jsx(Steps, { shown: shownPlies, total: plies.length, onStep: onStep, text: text })] })] })) }));
18
18
  }
19
19
  /** A button that saves the game as a file in the player's downloads, its tooltip saying what the file holds. */
20
20
  function SaveButton({ label, hint, onSave }) {
@@ -320,6 +320,27 @@
320
320
  color: var(--soft);
321
321
  }
322
322
 
323
+ /* A review nobody is finishing: the note, and beside it the button that brings the game back to the AI (D-109); neutral, as no error (Zoe) */
324
+ .review.incomplete,
325
+ .say.incomplete {
326
+ display: flex;
327
+ align-items: center;
328
+ justify-content: space-between;
329
+ gap: 8px;
330
+ margin: 0;
331
+ color: var(--soft);
332
+ }
333
+
334
+ .review.incomplete button,
335
+ .say.incomplete button {
336
+ flex: none;
337
+ }
338
+
339
+ /* On the bubble's tint the button keeps the card's ground, to read as one */
340
+ .say.incomplete button {
341
+ background: var(--card);
342
+ }
343
+
323
344
  .review ul {
324
345
  margin: 0;
325
346
  padding-left: 18px;
@@ -497,7 +518,8 @@
497
518
  padding: 8px;
498
519
  }
499
520
 
500
- .analysis-actions button {
521
+ .analysis-actions button,
522
+ .incomplete button {
501
523
  flex: 1;
502
524
  display: inline-flex;
503
525
  align-items: center;
@@ -513,7 +535,8 @@
513
535
  cursor: pointer;
514
536
  }
515
537
 
516
- .analysis-actions button:hover {
538
+ .analysis-actions button:hover,
539
+ .incomplete button:hover {
517
540
  border-color: var(--accent);
518
541
  }
519
542
 
@@ -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;
@@ -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
@@ -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",
@@ -494,7 +499,7 @@ const italian = {
494
499
  saveMoves: "PGN",
495
500
  saveMovesHint: "Solo le mosse, da aprire su Lichess o chess.com",
496
501
  noGameNow: "Nessuna partita in corso",
497
- analysing: "Analisi in corso",
502
+ analysing: "Analisi",
498
503
  badges: {
499
504
  brilliant: "Geniale",
500
505
  great: "Ottima mossa",
@@ -522,6 +527,9 @@ const italian = {
522
527
  mated: (side) => `Matto al ${side === "white" ? "Bianco" : "Nero"}`,
523
528
  },
524
529
  reviewing: (name) => `${name} sta analizzando la partita…`,
530
+ incomplete: "Analisi non completa",
531
+ complete: "Completa",
532
+ completeHint: "Completa l'analisi",
525
533
  you: "Tu",
526
534
  ai: "AI",
527
535
  sides: { white: "Bianco", black: "Nero" },
@@ -577,6 +585,8 @@ const italian = {
577
585
  table: {
578
586
  title: "Contro chi giochi?",
579
587
  choose: "Scegli la chat con cui giocare: parte in una finestra a parte, da tenere aperta.",
588
+ reviewTitle: "Chi analizza la partita?",
589
+ reviewChoose: "Scegli la chat che analizza la partita: parte in una finestra a parte, da tenere aperta.",
580
590
  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
591
  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
592
  noChat: "Nessuna chat al tavolo: scegline una",
@@ -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.
@@ -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.
@@ -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.
@@ -5,7 +5,7 @@ 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";
@@ -141,6 +141,9 @@ function answer(request, response, served) {
141
141
  send(response, 200, json, JSON.stringify(bellProbe(served.folder, served.watchers.size > 0, tableChat(served))));
142
142
  }
143
143
  else {
144
+ if (url.searchParams.get("analyst") === "gone") {
145
+ forgetGoneAnalyst(served);
146
+ }
144
147
  served.bell.listen(request, response);
145
148
  }
146
149
  }
@@ -148,6 +151,15 @@ function answer(request, response, served) {
148
151
  sendFile(response, path);
149
152
  }
150
153
  }
154
+ /** The chat's hook says no analyst runs any more (D-109): the game it held is free, and its review, unless finished, incomplete. */
155
+ function forgetGoneAnalyst(served) {
156
+ try {
157
+ forgetAnalyst(gameFolder(served.context.home()));
158
+ }
159
+ catch {
160
+ // A data folder that cannot be written keeps the analyst until half an hour has gone by.
161
+ }
162
+ }
151
163
  /** The chat at the table, as its probe says it: none on a page a chat serves itself. */
152
164
  function tableChat(served) {
153
165
  if (served.table === undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chesspal",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
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": {