chesspal 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (273) hide show
  1. package/README.md +99 -2
  2. package/dist/cli/chat-hook.d.ts +2 -0
  3. package/dist/cli/chat-hook.js +33 -0
  4. package/dist/cli/cli.d.ts +2 -0
  5. package/dist/cli/cli.js +28 -0
  6. package/dist/cli/command-line.d.ts +7 -0
  7. package/dist/cli/command-line.js +177 -0
  8. package/dist/commands/agents.d.ts +26 -0
  9. package/dist/commands/agents.js +72 -0
  10. package/dist/commands/ai-move.d.ts +28 -0
  11. package/dist/commands/ai-move.js +85 -0
  12. package/dist/commands/ai-say.d.ts +22 -0
  13. package/dist/commands/ai-say.js +58 -0
  14. package/dist/commands/analyse-game.d.ts +65 -0
  15. package/dist/commands/analyse-game.js +265 -0
  16. package/dist/commands/annotate-game.d.ts +39 -0
  17. package/dist/commands/annotate-game.js +131 -0
  18. package/dist/commands/answer-lines.d.ts +28 -0
  19. package/dist/commands/answer-lines.js +144 -0
  20. package/dist/commands/clock.d.ts +15 -0
  21. package/dist/commands/clock.js +45 -0
  22. package/dist/commands/coach.d.ts +45 -0
  23. package/dist/commands/coach.js +344 -0
  24. package/dist/commands/context.d.ts +67 -0
  25. package/dist/commands/context.js +37 -0
  26. package/dist/commands/delete-game.d.ts +7 -0
  27. package/dist/commands/delete-game.js +30 -0
  28. package/dist/commands/delete-review.d.ts +8 -0
  29. package/dist/commands/delete-review.js +34 -0
  30. package/dist/commands/draw-offer.d.ts +10 -0
  31. package/dist/commands/draw-offer.js +43 -0
  32. package/dist/commands/drawings.d.ts +13 -0
  33. package/dist/commands/drawings.js +25 -0
  34. package/dist/commands/end-game.d.ts +23 -0
  35. package/dist/commands/end-game.js +67 -0
  36. package/dist/commands/export-game.d.ts +14 -0
  37. package/dist/commands/export-game.js +27 -0
  38. package/dist/commands/game-summary.d.ts +78 -0
  39. package/dist/commands/game-summary.js +102 -0
  40. package/dist/commands/index.d.ts +18 -0
  41. package/dist/commands/index.js +64 -0
  42. package/dist/commands/init.d.ts +15 -0
  43. package/dist/commands/init.js +73 -0
  44. package/dist/commands/lead-input.d.ts +8 -0
  45. package/dist/commands/lead-input.js +21 -0
  46. package/dist/commands/levels.d.ts +9 -0
  47. package/dist/commands/levels.js +25 -0
  48. package/dist/commands/look.d.ts +31 -0
  49. package/dist/commands/look.js +64 -0
  50. package/dist/commands/new-game.d.ts +68 -0
  51. package/dist/commands/new-game.js +218 -0
  52. package/dist/commands/opening-book.d.ts +13 -0
  53. package/dist/commands/opening-book.js +84 -0
  54. package/dist/commands/page-link.d.ts +11 -0
  55. package/dist/commands/page-link.js +28 -0
  56. package/dist/commands/past-games.d.ts +11 -0
  57. package/dist/commands/past-games.js +47 -0
  58. package/dist/commands/play-move.d.ts +37 -0
  59. package/dist/commands/play-move.js +94 -0
  60. package/dist/commands/player-move.d.ts +13 -0
  61. package/dist/commands/player-move.js +51 -0
  62. package/dist/commands/player-say.d.ts +5 -0
  63. package/dist/commands/player-say.js +16 -0
  64. package/dist/commands/ponder.d.ts +52 -0
  65. package/dist/commands/ponder.js +179 -0
  66. package/dist/commands/position.d.ts +14 -0
  67. package/dist/commands/position.js +130 -0
  68. package/dist/commands/profiles.d.ts +69 -0
  69. package/dist/commands/profiles.js +163 -0
  70. package/dist/commands/registry.d.ts +59 -0
  71. package/dist/commands/registry.js +62 -0
  72. package/dist/commands/say.d.ts +16 -0
  73. package/dist/commands/say.js +84 -0
  74. package/dist/commands/speech.d.ts +8 -0
  75. package/dist/commands/speech.js +20 -0
  76. package/dist/commands/status.d.ts +35 -0
  77. package/dist/commands/status.js +128 -0
  78. package/dist/commands/study-games.d.ts +5 -0
  79. package/dist/commands/study-games.js +40 -0
  80. package/dist/commands/study.d.ts +38 -0
  81. package/dist/commands/study.js +106 -0
  82. package/dist/commands/view.d.ts +14 -0
  83. package/dist/commands/view.js +125 -0
  84. package/dist/core/board-text.d.ts +7 -0
  85. package/dist/core/board-text.js +18 -0
  86. package/dist/core/book.json +1 -0
  87. package/dist/core/clock.d.ts +45 -0
  88. package/dist/core/clock.js +109 -0
  89. package/dist/core/facts.d.ts +55 -0
  90. package/dist/core/facts.js +175 -0
  91. package/dist/core/game.d.ts +95 -0
  92. package/dist/core/game.js +352 -0
  93. package/dist/core/illegal.d.ts +19 -0
  94. package/dist/core/illegal.js +141 -0
  95. package/dist/core/index.d.ts +20 -0
  96. package/dist/core/index.js +12 -0
  97. package/dist/core/lead.d.ts +15 -0
  98. package/dist/core/lead.js +40 -0
  99. package/dist/core/levels.d.ts +20 -0
  100. package/dist/core/levels.js +33 -0
  101. package/dist/core/material.d.ts +9 -0
  102. package/dist/core/material.js +12 -0
  103. package/dist/core/moods.d.ts +21 -0
  104. package/dist/core/moods.js +45 -0
  105. package/dist/core/openings.d.ts +35 -0
  106. package/dist/core/openings.js +98 -0
  107. package/dist/core/openings.json +1 -0
  108. package/dist/core/pgn.d.ts +10 -0
  109. package/dist/core/pgn.js +213 -0
  110. package/dist/core/profile.d.ts +117 -0
  111. package/dist/core/profile.js +232 -0
  112. package/dist/core/record.d.ts +21 -0
  113. package/dist/core/record.js +25 -0
  114. package/dist/core/review.d.ts +80 -0
  115. package/dist/core/review.js +146 -0
  116. package/dist/games/ai-model.d.ts +5 -0
  117. package/dist/games/ai-model.js +15 -0
  118. package/dist/games/analysis.d.ts +12 -0
  119. package/dist/games/analysis.js +26 -0
  120. package/dist/games/archive.d.ts +63 -0
  121. package/dist/games/archive.js +242 -0
  122. package/dist/games/chat-start.d.ts +16 -0
  123. package/dist/games/chat-start.js +26 -0
  124. package/dist/games/coach.d.ts +57 -0
  125. package/dist/games/coach.js +144 -0
  126. package/dist/games/current-game.d.ts +56 -0
  127. package/dist/games/current-game.js +134 -0
  128. package/dist/games/files.d.ts +9 -0
  129. package/dist/games/files.js +53 -0
  130. package/dist/games/index.d.ts +11 -0
  131. package/dist/games/index.js +12 -0
  132. package/dist/games/plans.d.ts +9 -0
  133. package/dist/games/plans.js +23 -0
  134. package/dist/games/table-talk.d.ts +12 -0
  135. package/dist/games/table-talk.js +25 -0
  136. package/dist/games/talk.d.ts +36 -0
  137. package/dist/games/talk.js +131 -0
  138. package/dist/games/thoughts.d.ts +15 -0
  139. package/dist/games/thoughts.js +60 -0
  140. package/dist/home/data-folder.d.ts +12 -0
  141. package/dist/home/data-folder.js +55 -0
  142. package/dist/home/index.d.ts +2 -0
  143. package/dist/home/index.js +3 -0
  144. package/dist/home/open-folder.d.ts +2 -0
  145. package/dist/home/open-folder.js +14 -0
  146. package/dist/host-files/agents.d.ts +20 -0
  147. package/dist/host-files/agents.js +53 -0
  148. package/dist/host-files/host-files.d.ts +15 -0
  149. package/dist/host-files/host-files.js +182 -0
  150. package/dist/host-files/index.d.ts +2 -0
  151. package/dist/host-files/index.js +3 -0
  152. package/dist/index.d.ts +2 -0
  153. package/dist/index.js +3 -0
  154. package/dist/page/app/anchor.js +88 -0
  155. package/dist/page/app/app.js +458 -0
  156. package/dist/page/app/keys.js +94 -0
  157. package/dist/page/app/phase.js +30 -0
  158. package/dist/page/app/served.js +73 -0
  159. package/dist/page/avatars/avatars.js +19 -0
  160. package/dist/page/avatars/beanie.webp +0 -0
  161. package/dist/page/avatars/beard.webp +0 -0
  162. package/dist/page/avatars/bun.webp +0 -0
  163. package/dist/page/avatars/cat.webp +0 -0
  164. package/dist/page/avatars/curls.webp +0 -0
  165. package/dist/page/avatars/glasses.webp +0 -0
  166. package/dist/page/avatars/moustache.webp +0 -0
  167. package/dist/page/avatars/owl.webp +0 -0
  168. package/dist/page/avatars/short-hair.webp +0 -0
  169. package/dist/page/avatars/silver-bob.webp +0 -0
  170. package/dist/page/avatars/thought.js +10 -0
  171. package/dist/page/avatars/torretta.js +38 -0
  172. package/dist/page/base.css +160 -0
  173. package/dist/page/board/board.css +959 -0
  174. package/dist/page/board/board.js +125 -0
  175. package/dist/page/board/explain.js +58 -0
  176. package/dist/page/board/marks.js +160 -0
  177. package/dist/page/board/motion.js +71 -0
  178. package/dist/page/board/pointer.js +239 -0
  179. package/dist/page/board/squares.js +26 -0
  180. package/dist/page/dialogs/analyses.js +195 -0
  181. package/dist/page/dialogs/dialog.js +26 -0
  182. package/dist/page/dialogs/dialogs.css +1228 -0
  183. package/dist/page/dialogs/new-game.js +42 -0
  184. package/dist/page/dialogs/profiles.js +152 -0
  185. package/dist/page/dialogs/search.js +31 -0
  186. package/dist/page/dialogs/settings.js +42 -0
  187. package/dist/page/dialogs/studied.js +39 -0
  188. package/dist/page/dialogs/study.js +51 -0
  189. package/dist/page/game/badges.js +50 -0
  190. package/dist/page/game/browse.js +17 -0
  191. package/dist/page/game/card-fold.js +40 -0
  192. package/dist/page/game/column.js +51 -0
  193. package/dist/page/game/ending.js +73 -0
  194. package/dist/page/game/game.css +577 -0
  195. package/dist/page/game/move-list.js +72 -0
  196. package/dist/page/game/opening-book.js +14 -0
  197. package/dist/page/game/plies.js +82 -0
  198. package/dist/page/game/result-tile.js +14 -0
  199. package/dist/page/helps/helps.css +689 -0
  200. package/dist/page/helps/helps.js +220 -0
  201. package/dist/page/helps/view.js +127 -0
  202. package/dist/page/layout/fold-tab.js +11 -0
  203. package/dist/page/layout/folds.js +43 -0
  204. package/dist/page/layout/header.js +28 -0
  205. package/dist/page/layout/layout.css +834 -0
  206. package/dist/page/lead/lead.js +82 -0
  207. package/dist/page/lead/view.js +38 -0
  208. package/dist/page/main.js +8 -0
  209. package/dist/page/pieces/CREDITS.md +19 -0
  210. package/dist/page/pieces/capablanca-small.svg +20 -0
  211. package/dist/page/pieces/capablanca.svg +22 -0
  212. package/dist/page/pieces/nitida-small.svg +20 -0
  213. package/dist/page/pieces/nitida.svg +21 -0
  214. package/dist/page/pieces/pieces.js +28 -0
  215. package/dist/page/pieces/torretta-small.svg +20 -0
  216. package/dist/page/pieces/torretta.svg +21 -0
  217. package/dist/page/players/card.js +56 -0
  218. package/dist/page/players/clock-time.js +12 -0
  219. package/dist/page/players/model-name.js +20 -0
  220. package/dist/page/profile/profile.js +99 -0
  221. package/dist/page/profile/settings.js +77 -0
  222. package/dist/page/sounds/capture.ogg +0 -0
  223. package/dist/page/sounds/check.ogg +0 -0
  224. package/dist/page/sounds/draw.ogg +0 -0
  225. package/dist/page/sounds/flag.ogg +0 -0
  226. package/dist/page/sounds/mate.ogg +0 -0
  227. package/dist/page/sounds/move.ogg +0 -0
  228. package/dist/page/sounds/offer.ogg +0 -0
  229. package/dist/page/sounds/resign.ogg +0 -0
  230. package/dist/page/sounds/sounds.js +105 -0
  231. package/dist/page/style.css +9 -0
  232. package/dist/page/talk/talk.css +958 -0
  233. package/dist/page/talk/talk.js +165 -0
  234. package/dist/page/talk/voice.js +38 -0
  235. package/dist/page/text.js +711 -0
  236. package/dist/page/theme.css +292 -0
  237. package/dist/profiles/avatar.d.ts +23 -0
  238. package/dist/profiles/avatar.js +84 -0
  239. package/dist/profiles/front-matter.d.ts +9 -0
  240. package/dist/profiles/front-matter.js +33 -0
  241. package/dist/profiles/index.d.ts +3 -0
  242. package/dist/profiles/index.js +5 -0
  243. package/dist/profiles/profiles.d.ts +69 -0
  244. package/dist/profiles/profiles.js +254 -0
  245. package/dist/profiles/settings.d.ts +12 -0
  246. package/dist/profiles/settings.js +46 -0
  247. package/dist/server/bell.d.ts +68 -0
  248. package/dist/server/bell.js +225 -0
  249. package/dist/server/caller.d.ts +21 -0
  250. package/dist/server/caller.js +132 -0
  251. package/dist/server/mcp.d.ts +13 -0
  252. package/dist/server/mcp.js +207 -0
  253. package/dist/server/page/analyses.d.ts +23 -0
  254. package/dist/server/page/analyses.js +65 -0
  255. package/dist/server/page/files.d.ts +5 -0
  256. package/dist/server/page/files.js +92 -0
  257. package/dist/server/page/game.d.ts +6 -0
  258. package/dist/server/page/game.js +381 -0
  259. package/dist/server/page/http.d.ts +29 -0
  260. package/dist/server/page/http.js +68 -0
  261. package/dist/server/page/page.d.ts +41 -0
  262. package/dist/server/page/page.js +229 -0
  263. package/dist/server/page/profiles.d.ts +19 -0
  264. package/dist/server/page/profiles.js +215 -0
  265. package/dist/version.d.ts +2 -0
  266. package/dist/version.js +3 -0
  267. package/instructions/analysis.md +4 -0
  268. package/instructions/analyst-agent.md +8 -0
  269. package/instructions/analyst.md +4 -0
  270. package/instructions/coach.md +11 -0
  271. package/instructions/opponent.md +15 -0
  272. package/instructions/ponder.md +9 -0
  273. package/package.json +66 -5
@@ -0,0 +1,254 @@
1
+ // The player's profiles (D-23): profiles/<id>/ in the data folder (D-71), each with its profile.md, its games and its page settings, and the one in use;
2
+ // there is always one, the first made by Chesspal.
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { makeFolder, renameOver, writeAtomically } from "../games/index.js";
6
+ import { readFrontMatter, writeFrontMatter } from "./front-matter.js";
7
+ /** How the player says they play, from who has just learned to who plays tournaments. */
8
+ export const levels = ["learning", "casual", "regular", "tournament"];
9
+ /** Where a rating comes from. */
10
+ export const ratingSites = ["lichess", "chess.com", "fide"];
11
+ /** The lowest and highest rating a profile takes. */
12
+ export const ratingLimits = { lowest: 100, highest: 3500 };
13
+ /** The longest name a profile keeps. */
14
+ export const nameLimit = 40;
15
+ /** The avatars Zoe drew, for a player without an image of their own (D-45). */
16
+ export const drawnAvatars = ["short-hair", "bun", "glasses", "beanie", "beard", "curls", "moustache", "silver-bob", "cat", "owl"];
17
+ /** The profiles in the data folder, by their folders' names. */
18
+ export function listProfiles(root) {
19
+ return profileIds(root).map((id) => readProfile(root, id));
20
+ }
21
+ /**
22
+ * Makes the first profile when there is none, named after the system's account and new until the player completes it (D-71). Two processes starting
23
+ * together make one: the second finds its folder taken.
24
+ */
25
+ export function ensureProfile(root, account) {
26
+ const id = freeId(root, slugOf(account));
27
+ if (profileIds(root).length === 0 && claimed(root, id)) {
28
+ fillProfile(root, id, { name: account }, true);
29
+ }
30
+ }
31
+ /** The profile in use: the one active.json names, else the first. */
32
+ export function activeProfile(root) {
33
+ return readProfile(root, activeIdOf(root));
34
+ }
35
+ /** Where the games are: the folder of the profile in use. */
36
+ export function gameFolder(root) {
37
+ return profileFolder(root, activeIdOf(root));
38
+ }
39
+ /** A profile's folder. */
40
+ export function profileFolder(root, id) {
41
+ return join(root, folderName, id);
42
+ }
43
+ /**
44
+ * Makes a profile, its folder named from the player's name, and puts it in use unless told not to; the one Chesspal made, while new, becomes it instead,
45
+ * with its games, and its folder takes the player's name too (D-71).
46
+ */
47
+ export function createProfile(root, fields, use = true) {
48
+ const id = activeId(root);
49
+ if (id !== undefined && readProfile(root, id).new === true) {
50
+ const { level, rating, avatar } = fields;
51
+ const named = renamed(root, id, fields.name);
52
+ writeProfile(root, named, { name: fields.name, level, rating, avatar, new: false });
53
+ return readProfile(root, named);
54
+ }
55
+ let made = freeId(root, slugOf(fields.name));
56
+ // Another process may take the same id between the look and the folder: the next free one, then.
57
+ while (!claimed(root, made)) {
58
+ made = freeId(root, slugOf(fields.name));
59
+ }
60
+ return fillProfile(root, made, fields, false, use);
61
+ }
62
+ /** Every profile's folder, where games may be. */
63
+ export function gameFolders(root) {
64
+ return profileIds(root).map((id) => profileFolder(root, id));
65
+ }
66
+ /** Puts a profile in use. */
67
+ export function activateProfile(root, id) {
68
+ const ids = profileIds(root);
69
+ if (!ids.includes(id)) {
70
+ throw new Error(ids.length === 0 ? "There is no profile yet: make one first." : `There is no profile ${id}: the profiles are ${ids.join(", ")}.`);
71
+ }
72
+ writeActive(root, id);
73
+ return readProfile(root, id);
74
+ }
75
+ /** Changes what a profile says of the player, keeping the rest of profile.md as it was written: its other fields and the text below them. */
76
+ export function updateProfile(root, id, changes) {
77
+ writeProfile(root, id, changes);
78
+ return readProfile(root, id);
79
+ }
80
+ const folderName = "profiles";
81
+ const profileFile = "profile.md";
82
+ const activeFile = "active.json";
83
+ // Names Windows keeps for devices: no folder may take them.
84
+ const reserved = /^(con|prn|aux|nul|com\d|lpt\d)$/;
85
+ function profileIds(root) {
86
+ try {
87
+ return readdirSync(join(root, folderName), { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
88
+ }
89
+ catch {
90
+ return [];
91
+ }
92
+ }
93
+ function activeId(root) {
94
+ const named = activeNamed(root);
95
+ const ids = profileIds(root);
96
+ return named !== undefined && ids.includes(named) ? named : ids[0];
97
+ }
98
+ function activeIdOf(root) {
99
+ const id = activeId(root);
100
+ if (id === undefined) {
101
+ throw new Error(`There is no profile in ${join(root, folderName)}: run any Chesspal command, and it makes one.`);
102
+ }
103
+ return id;
104
+ }
105
+ /** Takes a profile's folder for this call alone: false when another process has made it already. */
106
+ function claimed(root, id) {
107
+ makeFolder(join(root, folderName));
108
+ const folder = profileFolder(root, id);
109
+ try {
110
+ mkdirSync(folder);
111
+ return true;
112
+ }
113
+ catch (error) {
114
+ if (error.code === "EEXIST") {
115
+ return false;
116
+ }
117
+ throw new Error(`${folder} could not be made (${error.message}): check that it is a folder, and can be written.`, { cause: error });
118
+ }
119
+ }
120
+ /** Writes a profile in the folder this call claimed and puts it in use when asked; on a failure the folder goes, which holds nothing of another's. */
121
+ function fillProfile(root, id, fields, made, use = true) {
122
+ try {
123
+ writeProfile(root, id, made ? { ...fields, new: true } : fields);
124
+ if (use) {
125
+ writeActive(root, id);
126
+ }
127
+ }
128
+ catch (error) {
129
+ rmSync(profileFolder(root, id), { recursive: true, force: true });
130
+ throw error;
131
+ }
132
+ return readProfile(root, id);
133
+ }
134
+ /**
135
+ * The folder of the profile Chesspal made, named after the account, moved to the player's name and put in use; it keeps the account's for a name without
136
+ * letters or digits, and where it cannot move, as with a file held open in it.
137
+ */
138
+ function renamed(root, id, name) {
139
+ const plain = plainName(name);
140
+ if (plain === "" || id === plain || (id.startsWith(`${plain}-`) && /^\d+$/.test(id.slice(plain.length + 1)))) {
141
+ return id;
142
+ }
143
+ const target = freeId(root, plain);
144
+ // On Linux and macOS a folder another process made between the look and the move would be replaced: two profiles made in the same instant.
145
+ try {
146
+ renameOver(profileFolder(root, id), profileFolder(root, target));
147
+ }
148
+ catch {
149
+ return id;
150
+ }
151
+ try {
152
+ writeActive(root, target);
153
+ }
154
+ catch (error) {
155
+ // Moved back, or the first folder would be put in use in its place.
156
+ renameOver(profileFolder(root, target), profileFolder(root, id));
157
+ throw error;
158
+ }
159
+ return target;
160
+ }
161
+ function activeNamed(root) {
162
+ try {
163
+ const { profile } = JSON.parse(readFileSync(join(root, folderName, activeFile), "utf8"));
164
+ return typeof profile === "string" && /^[^/\\.][^/\\]*$/.test(profile) ? profile : undefined;
165
+ }
166
+ catch {
167
+ return undefined;
168
+ }
169
+ }
170
+ function writeActive(root, id) {
171
+ writeAtomically(join(root, folderName, activeFile), `${JSON.stringify({ profile: id }, null, 2)}\n`);
172
+ }
173
+ function readProfile(root, id) {
174
+ const { fields } = readFrontMatter(profileText(root, id));
175
+ const value = Number(fields.rating);
176
+ const site = oneOf(ratingSites, fields.site);
177
+ const level = oneOf(levels, fields.level);
178
+ const avatar = oneOf(drawnAvatars, fields.avatar);
179
+ const rated = Number.isInteger(value) && value >= ratingLimits.lowest && value <= ratingLimits.highest && site !== undefined;
180
+ // A name written by hand is cut as the page and the CLI cut it: the AI reads it.
181
+ return {
182
+ id,
183
+ name: fields.name?.trim().slice(0, nameLimit).trim() || id,
184
+ ...(level === undefined ? {} : { level }),
185
+ ...(rated ? { rating: { value, site } } : {}),
186
+ ...(avatar === undefined ? {} : { avatar }),
187
+ ...(fields.new === "true" ? { new: true } : {}),
188
+ ...accountsIn(fields),
189
+ };
190
+ }
191
+ /** The player's names on the sites, from profile.md's lichess and chesscom lines; none without either. */
192
+ function accountsIn(fields) {
193
+ const named = (site) => fields[site]?.trim().slice(0, nameLimit).trim() || undefined;
194
+ const lichess = named("lichess");
195
+ const chesscom = named("chesscom");
196
+ if (lichess === undefined && chesscom === undefined) {
197
+ return {};
198
+ }
199
+ return { accounts: { ...(lichess === undefined ? {} : { lichess }), ...(chesscom === undefined ? {} : { chesscom }) } };
200
+ }
201
+ // Writes the fields that change, and keeps the others as the file has them, even those this version does not read.
202
+ function writeProfile(root, id, changes) {
203
+ const { fields, body } = readFrontMatter(profileText(root, id));
204
+ const written = { ...fields };
205
+ if (changes.name !== undefined) {
206
+ written.name = changes.name;
207
+ }
208
+ if ("level" in changes) {
209
+ written.level = changes.level;
210
+ }
211
+ if ("rating" in changes) {
212
+ written.rating = changes.rating?.value;
213
+ written.site = changes.rating?.site;
214
+ }
215
+ if ("avatar" in changes) {
216
+ written.avatar = changes.avatar;
217
+ }
218
+ if ("accounts" in changes) {
219
+ written.lichess = changes.accounts?.lichess;
220
+ written.chesscom = changes.accounts?.chesscom;
221
+ }
222
+ if ("new" in changes) {
223
+ written.new = changes.new === true ? "true" : undefined;
224
+ }
225
+ writeAtomically(join(profileFolder(root, id), profileFile), writeFrontMatter(written, body));
226
+ }
227
+ function profileText(root, id) {
228
+ try {
229
+ return readFileSync(join(profileFolder(root, id), profileFile), "utf8");
230
+ }
231
+ catch {
232
+ return "";
233
+ }
234
+ }
235
+ /** A folder's name from the player's name: its plain letters and digits, or player for a name without any. */
236
+ function slugOf(name) {
237
+ return plainName(name) || "player";
238
+ }
239
+ /** A name in plain letters and digits, lower case, words joined by hyphens; empty for a name without any. */
240
+ function plainName(name) {
241
+ return name.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+/, "").slice(0, 32).replace(/-+$/, "");
242
+ }
243
+ function freeId(root, slug) {
244
+ const taken = (id) => reserved.test(id) || existsSync(profileFolder(root, id));
245
+ let id = slug;
246
+ for (let number = 2; taken(id); number++) {
247
+ id = `${slug}-${number}`;
248
+ }
249
+ return id;
250
+ }
251
+ // A value of a list, as a hand may write it: in capitals too.
252
+ function oneOf(values, value) {
253
+ return values.find((one) => one === value?.toLowerCase());
254
+ }
@@ -0,0 +1,12 @@
1
+ /** The page's settings, by name: text, numbers and flags. */
2
+ export type PageSettings = Readonly<Record<string, string | number | boolean>>;
3
+ /** A profile's settings, or undefined when it has none yet, or they cannot be read. */
4
+ export declare function profileSettings(root: string, id: string): PageSettings | undefined;
5
+ /** Whether the player has turned the helps on (D-78), in the settings of the profile whose games are in `folder`: off without them. */
6
+ export declare function helpsIn(folder: string): boolean;
7
+ /** The language of the player of the profile whose games are in `folder`: the page's, or the system's when the page follows the browser. */
8
+ export declare function languageIn(folder: string, systemLanguage: string | undefined): string | undefined;
9
+ /** Saves a profile's settings, over the ones before. */
10
+ export declare function saveProfileSettings(root: string, id: string, settings: PageSettings): void;
11
+ /** Settings from what JSON gave: an object of text, numbers and flags, at most a few dozen; undefined for anything else. */
12
+ export declare function settingsOf(value: unknown): PageSettings | undefined;
@@ -0,0 +1,46 @@
1
+ // The page's settings of a profile (D-56): settings.json in its folder, kept as the page sends them; the page checks each one as it reads them.
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { writeAtomically } from "../games/index.js";
5
+ import { profileFolder } from "./profiles.js";
6
+ /** A profile's settings, or undefined when it has none yet, or they cannot be read. */
7
+ export function profileSettings(root, id) {
8
+ try {
9
+ return settingsOf(JSON.parse(readFileSync(join(profileFolder(root, id), fileName), "utf8")));
10
+ }
11
+ catch {
12
+ return undefined;
13
+ }
14
+ }
15
+ /** Whether the player has turned the helps on (D-78), in the settings of the profile whose games are in `folder`: off without them. */
16
+ export function helpsIn(folder) {
17
+ return settingsIn(folder)?.helps === true;
18
+ }
19
+ /** The language of the player of the profile whose games are in `folder`: the page's, or the system's when the page follows the browser. */
20
+ export function languageIn(folder, systemLanguage) {
21
+ const language = settingsIn(folder)?.language;
22
+ return typeof language === "string" && language !== "auto" ? language : systemLanguage;
23
+ }
24
+ /** Saves a profile's settings, over the ones before. */
25
+ export function saveProfileSettings(root, id, settings) {
26
+ writeAtomically(join(profileFolder(root, id), fileName), `${JSON.stringify(settings, null, 2)}\n`);
27
+ }
28
+ /** Settings from what JSON gave: an object of text, numbers and flags, at most a few dozen; undefined for anything else. */
29
+ export function settingsOf(value) {
30
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
31
+ return undefined;
32
+ }
33
+ const entries = Object.entries(value);
34
+ const plain = entries.every(([, one]) => ["string", "number", "boolean"].includes(typeof one) && (typeof one !== "string" || one.length <= 64));
35
+ return plain && entries.length <= 40 ? Object.fromEntries(entries) : undefined;
36
+ }
37
+ const fileName = "settings.json";
38
+ /** The settings of the profile whose games are in `folder`; undefined without them. */
39
+ function settingsIn(folder) {
40
+ try {
41
+ return settingsOf(JSON.parse(readFileSync(join(folder, fileName), "utf8")));
42
+ }
43
+ catch {
44
+ return undefined;
45
+ }
46
+ }
@@ -0,0 +1,68 @@
1
+ import { type IncomingMessage, type ServerResponse } from "node:http";
2
+ /** What the chat hears from the doorbell: one line for whatever the player did, never what, only where to read it. */
3
+ export declare const bellLine = "The player acted on the board page: call status to see it.";
4
+ /**
5
+ * The doorbell's end on the page's server: the hook of the chat that ended its turn waits here, one at a time, and hears "ring" when the player moves or
6
+ * writes on the page. A ring no hook heard waits for the next one, while it is still news: the chat, awake, may have read it already.
7
+ */
8
+ export declare class Bell {
9
+ private listener;
10
+ private pending;
11
+ private busy;
12
+ private woken;
13
+ private readonly news;
14
+ private readonly changed;
15
+ /**
16
+ * `news` says whether a ring no hook heard is still worth waking the chat for; `changed` is told when what the page shows of the chat may change: it
17
+ * starts or stops listening, the doorbell wakes it, its turn ends.
18
+ */
19
+ constructor(news?: () => boolean, changed?: () => void);
20
+ /** Whether the chat is in a turn, as far as the doorbell knows: a chat stopped with Esc stays in it until its next tool. */
21
+ get inTurn(): boolean;
22
+ /** Whether the doorbell woke the chat in the turn it is in. */
23
+ get woke(): boolean;
24
+ /** Whether the chat hears the player: in a turn, or waiting for the bell with a hook. */
25
+ get listening(): boolean;
26
+ /** The chat calls a tool, so it is in a turn: true when nobody was listening before. */
27
+ chatCalls(): boolean;
28
+ /** The player acted on the page: the hook waiting hears it now, or the next one as soon as it comes. */
29
+ ring(): void;
30
+ /** A hook waits for the bell, so the chat's turn has ended; the hook before it, of a turn already ended, is let go. */
31
+ listen(request: IncomingMessage, response: ServerResponse): void;
32
+ private tellIf;
33
+ }
34
+ /** Whose page answers at a port, for the hook to find its own: the chat's session, the player's folder, and whether a browser has the page open. */
35
+ export declare function bellProbe(folder: string, open?: boolean): BellProbe;
36
+ /** What a page says of itself at `/api/bell?probe`. */
37
+ export interface BellProbe {
38
+ session: string | null;
39
+ folder: string;
40
+ open: boolean;
41
+ }
42
+ /** The ports a folder's page tries first: one in each of a few ranges, by its path, since Windows may reserve a range. The hook looks for it there. */
43
+ export declare function pagePorts(folder: string): number[];
44
+ /** What Codex's Stop hook answers to wake the chat: its turn goes on with the doorbell's line. */
45
+ export declare const codexBellAnswer: string;
46
+ /**
47
+ * Reads the hook's input, waits for the bell, and ends the process as the host reads it: for Claude Code's asyncRewake, 2 with the line on stderr; for
48
+ * Codex's Stop hook, 0 with its answer on stdout, since Windows PowerShell, which runs Codex's hooks, turns an exit code 2 into 1. Else 0, saying nothing.
49
+ */
50
+ export declare function listen(codex?: boolean): Promise<void>;
51
+ /**
52
+ * Waits at the page of the hook's chat until the player moves there: true then; false when there is no page to wait at, or another hook takes its place.
53
+ * The chat's own page is the one with its session; after /clear, which gives the hook a new one, the only page of its folder.
54
+ */
55
+ export declare function waitForBell(input: string, ports?: number[]): Promise<boolean>;
56
+ /** What the hook heard at the page of its chat, in its folder: the bell, or why not. */
57
+ export type BellHeard = {
58
+ folder: string;
59
+ rang: true;
60
+ } | {
61
+ folder: string;
62
+ rang: false;
63
+ why: string;
64
+ };
65
+ /** Waits at the page of the hook's chat, as waitForBell does, and says what it heard. */
66
+ export declare function bellHeard(input: string, ports?: number[]): Promise<BellHeard>;
67
+ /** Adds a line to the log of the hooks that heard no bell, keeping its last lines; a log that cannot be written never stops the hook. */
68
+ export declare function noteSilence(folder: string, why: string, now?: Date): void;
@@ -0,0 +1,225 @@
1
+ // The doorbell for the page (D-47): `chesspal listen`, the Stop hook init writes for Claude Code and Codex, waits at the page of its chat and, when
2
+ // the player moves or writes there, tells the chat to call status. It never carries the move, the words or the position: status reads them fresh.
3
+ import { createHash } from "node:crypto";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { get } from "node:http";
6
+ import { join, resolve } from "node:path";
7
+ import { writeAtomically } from "../games/index.js";
8
+ /** What the chat hears from the doorbell: one line for whatever the player did, never what, only where to read it. */
9
+ export const bellLine = "The player acted on the board page: call status to see it.";
10
+ /**
11
+ * The doorbell's end on the page's server: the hook of the chat that ended its turn waits here, one at a time, and hears "ring" when the player moves or
12
+ * writes on the page. A ring no hook heard waits for the next one, while it is still news: the chat, awake, may have read it already.
13
+ */
14
+ export class Bell {
15
+ listener;
16
+ pending = false;
17
+ // The chat is in a turn: it called a tool, or a hook woke it, and its turn has not ended yet. A chat never started, or just cleared, is not.
18
+ busy = false;
19
+ // The doorbell woke the chat in this turn: the player acted on the page, and the chat goes to see it.
20
+ woken = false;
21
+ news;
22
+ changed;
23
+ /**
24
+ * `news` says whether a ring no hook heard is still worth waking the chat for; `changed` is told when what the page shows of the chat may change: it
25
+ * starts or stops listening, the doorbell wakes it, its turn ends.
26
+ */
27
+ constructor(news = () => true, changed = () => undefined) {
28
+ this.news = news;
29
+ this.changed = changed;
30
+ }
31
+ /** Whether the chat is in a turn, as far as the doorbell knows: a chat stopped with Esc stays in it until its next tool. */
32
+ get inTurn() {
33
+ return this.busy;
34
+ }
35
+ /** Whether the doorbell woke the chat in the turn it is in. */
36
+ get woke() {
37
+ return this.woken;
38
+ }
39
+ /** Whether the chat hears the player: in a turn, or waiting for the bell with a hook. */
40
+ get listening() {
41
+ return this.busy || this.listener !== undefined;
42
+ }
43
+ /** The chat calls a tool, so it is in a turn: true when nobody was listening before. */
44
+ chatCalls() {
45
+ const asleep = !this.listening;
46
+ this.busy = true;
47
+ this.tellIf(asleep);
48
+ return asleep;
49
+ }
50
+ /** The player acted on the page: the hook waiting hears it now, or the next one as soon as it comes. */
51
+ ring() {
52
+ if (this.listener === undefined) {
53
+ this.pending = true;
54
+ return;
55
+ }
56
+ this.listener.end("ring");
57
+ this.listener = undefined;
58
+ this.busy = true;
59
+ this.woken = true;
60
+ this.changed();
61
+ }
62
+ /** A hook waits for the bell, so the chat's turn has ended; the hook before it, of a turn already ended, is let go. */
63
+ listen(request, response) {
64
+ response.writeHead(200, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
65
+ const missed = this.pending;
66
+ this.pending = false;
67
+ if (missed && this.news()) {
68
+ response.end("ring");
69
+ this.busy = true;
70
+ this.woken = true;
71
+ this.changed();
72
+ return;
73
+ }
74
+ this.busy = false;
75
+ this.woken = false;
76
+ this.listener?.end("dismissed");
77
+ this.listener = response;
78
+ this.changed();
79
+ request.on("close", () => {
80
+ if (this.listener === response) {
81
+ // The hook went without the bell (the chat stopped, cleared or closed): nobody hears the player until the chat calls a tool again.
82
+ this.listener = undefined;
83
+ this.tellIf(!this.busy);
84
+ }
85
+ });
86
+ }
87
+ tellIf(changed) {
88
+ if (changed) {
89
+ this.changed();
90
+ }
91
+ }
92
+ }
93
+ /** Whose page answers at a port, for the hook to find its own: the chat's session, the player's folder, and whether a browser has the page open. */
94
+ export function bellProbe(folder, open = false) {
95
+ return { session: process.env.CLAUDE_CODE_SESSION_ID ?? null, folder: resolve(folder), open };
96
+ }
97
+ /** The ports a folder's page tries first: one in each of a few ranges, by its path, since Windows may reserve a range. The hook looks for it there. */
98
+ export function pagePorts(folder) {
99
+ const offset = createHash("sha256").update(resolve(folder).toLowerCase()).digest()[0] % 10;
100
+ return [5870, 6870, 7870].map((base) => base + offset);
101
+ }
102
+ /** What Codex's Stop hook answers to wake the chat: its turn goes on with the doorbell's line. */
103
+ export const codexBellAnswer = JSON.stringify({ decision: "block", reason: bellLine });
104
+ /**
105
+ * Reads the hook's input, waits for the bell, and ends the process as the host reads it: for Claude Code's asyncRewake, 2 with the line on stderr; for
106
+ * Codex's Stop hook, 0 with its answer on stdout, since Windows PowerShell, which runs Codex's hooks, turns an exit code 2 into 1. Else 0, saying nothing.
107
+ */
108
+ export async function listen(codex = false) {
109
+ let input = "";
110
+ for await (const chunk of process.stdin) {
111
+ input += String(chunk);
112
+ }
113
+ const heard = await bellHeard(input).catch((error) => ({ folder: chatFolder(hookInput(input)), rang: false, why: `failed: ${error.message}` }));
114
+ // Codex's turn ends with a hook that heard no bell, and the player's next move on the page wakes nobody: the log keeps why.
115
+ if (codex && !heard.rang) {
116
+ noteSilence(heard.folder, heard.why);
117
+ }
118
+ // It ends as soon as it knows: the chat hears the hook only once it ends.
119
+ if (!heard.rang) {
120
+ process.exit(0);
121
+ }
122
+ else if (codex) {
123
+ process.stdout.write(`${codexBellAnswer}\n`, () => process.exit(0));
124
+ }
125
+ else {
126
+ process.stderr.write(`${bellLine}\n`, () => process.exit(2));
127
+ }
128
+ }
129
+ /**
130
+ * Waits at the page of the hook's chat until the player moves there: true then; false when there is no page to wait at, or another hook takes its place.
131
+ * The chat's own page is the one with its session; after /clear, which gives the hook a new one, the only page of its folder.
132
+ */
133
+ export async function waitForBell(input, ports) {
134
+ return (await bellHeard(input, ports)).rang;
135
+ }
136
+ /** Waits at the page of the hook's chat, as waitForBell does, and says what it heard. */
137
+ export async function bellHeard(input, ports) {
138
+ const hook = hookInput(input);
139
+ const folder = chatFolder(hook);
140
+ const tried = ports ?? pagePorts(folder);
141
+ const probes = await Promise.all(tried.map(async (port) => ({ port, said: probeOf(await body(port, "/api/bell?probe", probeWait)) })));
142
+ const ours = probes.flatMap(({ port, said }) => (said !== undefined && samePath(said.folder, folder) ? [{ port, said }] : []));
143
+ const port = chatPage(ours, hook.session)?.port;
144
+ if (port === undefined) {
145
+ const others = probes.flatMap((one) => (one.said === undefined ? [] : [`${one.port} is ${one.said.folder}'s`]));
146
+ const open = ours.filter(({ said }) => said.open).length;
147
+ 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";
148
+ return { folder, rang: false, why: `${pages} at ports ${tried.join(", ")}${others.length > 0 ? `; ${others.join(", ")}` : ""}` };
149
+ }
150
+ const said = await body(port, "/api/bell");
151
+ 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}"` };
152
+ }
153
+ // How long a port may take to say whose page it is: one of this computer answers at once.
154
+ const probeWait = 1000;
155
+ // The log of Codex's hooks that heard no bell, in the chat's folder: its last lines only.
156
+ const silenceLog = "listen.log";
157
+ const silenceLines = 100;
158
+ /** Adds a line to the log of the hooks that heard no bell, keeping its last lines; a log that cannot be written never stops the hook. */
159
+ export function noteSilence(folder, why, now = new Date()) {
160
+ const path = join(folder, silenceLog);
161
+ try {
162
+ const before = existsSync(path) ? readFileSync(path, "utf8").split("\n").filter((line) => line !== "") : [];
163
+ writeAtomically(path, `${[...before, `${now.toISOString()} ${why}`].slice(-silenceLines).join("\n")}\n`);
164
+ }
165
+ catch {
166
+ // The log only explains; the hook ends the same.
167
+ }
168
+ }
169
+ /** The chat's folder, where its server started: Claude Code names it, and the hook's cwd follows the cd's of the chat. */
170
+ function chatFolder(hook) {
171
+ return resolve(process.env.CLAUDE_PROJECT_DIR ?? hook.cwd ?? process.cwd());
172
+ }
173
+ function hookInput(input) {
174
+ try {
175
+ const parsed = JSON.parse(input);
176
+ return {
177
+ ...(typeof parsed.session_id === "string" ? { session: parsed.session_id } : {}),
178
+ ...(typeof parsed.cwd === "string" ? { cwd: parsed.cwd } : {}),
179
+ };
180
+ }
181
+ catch {
182
+ return {};
183
+ }
184
+ }
185
+ /**
186
+ * 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
187
+ * gives the page no session, and a chat closed may leave its page behind for a while.
188
+ */
189
+ function chatPage(pages, session) {
190
+ const open = pages.filter(({ said }) => said.open);
191
+ return pages.find(({ said }) => session !== undefined && said.session === session) ?? (pages.length === 1 ? pages[0] : open.length === 1 ? open[0] : undefined);
192
+ }
193
+ function probeOf(said) {
194
+ try {
195
+ const parsed = said === undefined ? undefined : JSON.parse(said);
196
+ return parsed === undefined || typeof parsed.folder !== "string"
197
+ ? undefined
198
+ : { session: typeof parsed.session === "string" ? parsed.session : null, folder: parsed.folder, open: parsed.open === true };
199
+ }
200
+ catch {
201
+ return undefined;
202
+ }
203
+ }
204
+ function samePath(one, other) {
205
+ return process.platform === "win32" ? resolve(one).toLowerCase() === resolve(other).toLowerCase() : resolve(one) === resolve(other);
206
+ }
207
+ /** The body a page of this computer answers with, once it ends; undefined when it does not answer, or not with 200. */
208
+ function body(port, path, wait) {
209
+ return new Promise((done) => {
210
+ // A connection of its own: Node's shared one ends a request idle for five seconds, and the doorbell waits for hours.
211
+ const request = get({ host: "127.0.0.1", port, path, agent: false, ...(wait === undefined ? {} : { timeout: wait }) }, (response) => {
212
+ let text = "";
213
+ response.setEncoding("utf8");
214
+ response.on("data", (chunk) => {
215
+ text += chunk;
216
+ });
217
+ response.on("end", () => done(response.statusCode === 200 ? text : undefined));
218
+ response.on("error", () => done(undefined));
219
+ });
220
+ if (wait !== undefined) {
221
+ request.on("timeout", () => request.destroy());
222
+ }
223
+ request.on("error", () => done(undefined));
224
+ });
225
+ }
@@ -0,0 +1,21 @@
1
+ import type { Caller } from "../commands/index.ts";
2
+ /** The host as it names itself to the server. */
3
+ export interface Client {
4
+ name: string;
5
+ title?: string | undefined;
6
+ }
7
+ /** Who made the call with these metadata: the model and effort the host ties to it, else the host's name; undefined without a host. */
8
+ export declare function callerOf(meta: Record<string, unknown> | undefined, client: Client | undefined, folder: string, wait?: number): Promise<Caller | undefined>;
9
+ /** Who in Claude Code made a tool call: the chat itself, or one of its agents by its type. */
10
+ export type CallingAgent = {
11
+ chat: true;
12
+ } | {
13
+ agent: string;
14
+ };
15
+ /**
16
+ * Who in Claude Code made a tool call: the chat, when its line is in the chat's transcript, or the agent whose file has it, by the type Claude Code notes
17
+ * beside that file (Priya). Undefined when it cannot be told: another host, no chat noted, no line, or no type.
18
+ */
19
+ export declare function callingAgent(meta: Record<string, unknown> | undefined, folder: string, wait?: number): Promise<CallingAgent | undefined>;
20
+ /** Whether an agent's tool may answer this caller: one of its agents, or a caller that cannot be told, since the tool's description is the first guard. */
21
+ export declare function allowedCaller(caller: CallingAgent | undefined, agents: readonly string[]): boolean;